Merge branch 'master' into mfarhan/drop-py38-add-py312

This commit is contained in:
Muhammad Soban Javed
2024-12-10 15:54:18 +05:00
committed by GitHub
399 changed files with 21805 additions and 14446 deletions

View File

@@ -9,13 +9,13 @@
# Via Django
auth.Group:
".. no_pii:" : "No PII"
".. no_pii:": "No PII"
auth.Permission:
".. no_pii:" : "No PII"
".. no_pii:": "No PII"
auth.User:
".. pii:": "Contains username, password, and email address, retired in AccountRetirementView"
".. pii_types:" : username, email_address, password
".. pii_retirement:" : local_api
".. pii_types:": username, email_address, password
".. pii_retirement:": local_api
contenttypes.ContentType:
".. no_pii:": "No PII"
admin.LogEntry:
@@ -27,6 +27,66 @@ sessions.Session:
sites.Site:
".. no_pii:": "No PII"
# Automatically generated edx-platform models that can't be annotated
calendar_sync.HistoricalUserCalendarSyncConfig:
".. no_pii:": "No PII"
certificates.HistoricalCertificateAllowlist:
".. no_pii:": "No PII"
certificates.HistoricalCertificateDateOverride:
".. no_pii:": "No PII"
certificates.HistoricalCertificateInvalidation:
".. no_pii:": "No PII"
certificates.HistoricalGeneratedCertificate:
".. pii:": "PII can exist in the generated certificate linked to in this model. Certificate data is currently retained."
".. pii_types:": "name, username"
".. pii_retirement:": "retained"
course_apps.HistoricalCourseAppStatus:
".. no_pii:": "No PII"
course_goals.HistoricalCourseGoal:
".. no_pii:": "No PII"
course_live.HistoricalCourseLiveConfiguration:
".. no_pii:": "No PII"
course_modes.HistoricalCourseMode:
".. no_pii:": "No PII"
course_overviews.HistoricalCourseOverview:
".. no_pii:": "No PII"
discussions.HistoricalDiscussionsConfiguration:
".. no_pii:": "No PII"
entitlements.HistoricalCourseEntitlement:
".. no_pii:": "No PII"
entitlements.HistoricalCourseEntitlementSupportDetail:
".. no_pii:": "No PII"
experiments.HistoricalExperimentKeyValue:
".. no_pii:": "No PII"
external_user_ids.HistoricalExternalId:
".. no_pii:": "We store external_user_id here, but do not consider that PII under OEP-30."
external_user_ids.HistoricalExternalIdType:
".. no_pii:": "No PII"
grades.HistoricalPersistentSubsectionGradeOverride:
".. no_pii:": "No PII"
instructor_task.HistoricalInstructorTaskSchedule:
".. no_pii:": "No PII"
program_enrollments.HistoricalProgramCourseEnrollment:
".. no_pii:": "No PII"
program_enrollments.HistoricalProgramEnrollment:
".. pii:": "PII is found in the external key for a program enrollment"
".. pii_types:": "other"
".. pii_retirement:": "local_api"
programs.HistoricalProgramDiscussionsConfiguration:
".. no_pii:": "No PII"
programs.HistoricalProgramLiveConfiguration:
".. no_pii:": "No PII"
schedules.HistoricalSchedule:
".. no_pii:": "No PII"
split_modulestore_django.HistoricalSplitModulestoreCourseIndex:
".. no_pii:": "No PII"
student.HistoricalCourseEnrollment:
".. no_pii:": "No PII"
student.HistoricalManualEnrollmentAudit:
".. pii:": "Contains enrolled_email, retired in LMSAccountRetirementView"
".. pii_types:": "email_address"
".. pii_retirement:": "local_api"
# Automatically generated models in edx-enterprise that can't be annotated there
consent.HistoricalDataSharingConsent:
".. pii:": "The username field inherited from Consent contains PII."
@@ -45,7 +105,7 @@ enterprise.HistoricalEnterpriseCustomerCatalog:
enterprise.HistoricalEnterpriseCustomerEntitlement:
".. no_pii:": "No PII"
# Via ORA2
# Via edx-ora2, these can be removed once the models are annotated for real
assessment.Assessment:
".. no_pii:": "No PII"
assessment.AssessmentFeedback:
@@ -127,10 +187,24 @@ djcelery.TaskState:
djcelery.WorkerState:
".. no_pii:": "No PII"
# Via django-celery-results
django_celery_results.ChordCounter:
".. no_pii:": "No PII"
django_celery_results.GroupResult:
".. no_pii:": "No PII"
django_celery_results.TaskResult:
".. no_pii:": "No PII"
# Via edx-oauth2-provider https://github.com/edx/edx-oauth2-provider
edx_oauth2_provider.TrustedClient:
".. no_pii:": "No PII"
# Via edx-name-affirmation, not part of the openedx org
edx_name_affirmation.HistoricalVerifiedName:
".. pii:": "Contains name fields."
".. pii_types:": "name"
".. pii_retirement:": "local_api"
# Via VAL
edxval.CourseVideo:
".. no_pii:": "No PII"
@@ -149,6 +223,12 @@ edxval.VideoImage:
edxval.VideoTranscript:
".. no_pii:": "No PII"
# Via PyLTI1p3
lti1p3_tool_config.LtiTool:
".. no_pii:": "No PII"
lti1p3_tool_config.LtiToolKey:
".. no_pii:": "No PII"
# Via Milestones
milestones.CourseContentMilestone:
".. no_pii:": "No PII"
@@ -190,6 +270,10 @@ oauth2_provider.Grant:
".. pii:": "Contains 3rd party authentication secrets. Retired in DeactivateLogoutView."
".. pii_types:": password, other
".. pii_retirement:": local_api
oauth2_provider.IDToken:
".. pii:": "Contains 3rd party authentication secrets, currently this is retained until the token times out, but should be retired explicitly with the other models from this package."
".. pii_types:": password, other
".. pii_retirement:": retained
oauth2_provider.RefreshToken:
".. pii:": "Contains 3rd party authentication secrets. Retired in DeactivateLogoutView."
".. pii_types:": password, other
@@ -250,6 +334,8 @@ submissions.StudentItem:
".. no_pii:": "No PII"
submissions.Submission:
".. no_pii:": "No PII"
submissions.TeamSubmission:
".. no_pii:": "No PII"
# Via sorl-thumbnail https://github.com/jazzband/sorl-thumbnail
thumbnail.KVStore:

2
.github/CODEOWNERS vendored
View File

@@ -22,7 +22,7 @@ openedx/core/djangoapps/enrollments/ @openedx/2U-
openedx/core/djangoapps/heartbeat/
openedx/core/djangoapps/oauth_dispatch
openedx/core/djangoapps/user_api/ @openedx/2U-aperture
openedx/core/djangoapps/user_authn/ @openedx/2U-vanguards
openedx/core/djangoapps/user_authn/ @openedx/2U-infinity
openedx/core/djangoapps/verified_track_content/ @openedx/2u-infinity
openedx/features/course_experience/
xmodule/

View File

@@ -32,7 +32,11 @@
// When adding an ignoreDep, please include a reason and a public link that we can use to follow up and ensure
// that the ignoreDep is removed.
// This can be done as a comment within the ignoreDeps list.
"ignoreDeps": [],
"ignoreDeps": [
// karma-spec-reporter>0.20.0 does not seem compatible with our super-old 2016 Karma version (0.13.22).
// Ticket link: None, as upgrading Karma does not strike as worth the benefit.
"karma-spec-reporter"
],
"timezone": "America/New_York",
"prConcurrentLimit": 3,
"enabledManagers": ["npm"]

View File

@@ -15,7 +15,7 @@ defaults:
jobs:
check-requirements:
name: Compile requirements
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
# Only run remaining steps if there are changes to requirements/**

View File

@@ -10,7 +10,7 @@ jobs:
matrix:
python-version:
- "3.11"
os: ["ubuntu-22.04"]
os: ["ubuntu-24.04"]
steps:
- uses: actions/checkout@v4

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest]
node-version: [18, 20]
node-version: [20]
python-version:
- "3.11"
@@ -28,7 +28,7 @@ jobs:
node-version: ${{ matrix.node-version }}
- name: Setup npm
run: npm i -g npm@10.5.x
run: npm i -g npm@10.7.x
- name: Install Firefox 123.0
run: |

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-22.04]
os: [ubuntu-24.04]
python-version:
- "3.11"
# 'pinned' is used to install the latest patch version of Django
@@ -126,7 +126,7 @@ jobs:
if: always()
needs:
- check_migrations
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- name: Decide whether the needed jobs succeeded or failed
# uses: re-actors/alls-green@v1.2.1

View File

@@ -8,7 +8,7 @@ on:
jobs:
run-pylint:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
@@ -75,7 +75,7 @@ jobs:
if: always()
needs:
- run-pylint
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- name: Decide whether the needed jobs succeeded or failed
# uses: re-actors/alls-green@v1.2.1

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-22.04]
os: [ubuntu-24.04]
python-version:
- "3.11"
node-version: [20]

View File

@@ -12,11 +12,11 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-22.04]
os: [ubuntu-24.04]
python-version:
- "3.11"
node-version: [18, 20]
npm-version: [10.5.x]
node-version: [20]
npm-version: [10.7.x]
mongo-version:
- "7.0"

View File

@@ -239,7 +239,6 @@
"cms/djangoapps/cms_user_tasks/",
"cms/djangoapps/course_creators/",
"cms/djangoapps/export_course_metadata/",
"cms/djangoapps/maintenance/",
"cms/djangoapps/models/",
"cms/djangoapps/pipeline_js/",
"cms/djangoapps/xblock_config/",

View File

@@ -15,7 +15,7 @@ concurrency:
jobs:
run-tests:
name: ${{ matrix.shard_name }}(py=${{ matrix.python-version }},dj=${{ matrix.django-version }},mongo=${{ matrix.mongo-version }})
runs-on: ubuntu-22.04
runs-on: ${{ matrix.os-version }}
strategy:
matrix:
python-version:
@@ -43,22 +43,27 @@ jobs:
- "xmodule-with-cms"
mongo-version:
- "7.0"
os-version:
- ubuntu-24.04
# We only need to test older versions of Mongo with modules that directly interface with Mongo (that is: xmodule.modulestore)
# This code is left here as an example for future refernce in case we need to reduce the number of shards we're
# testing but still have good confidence with older versions of mongo. We use Mongo 4.4 in the example.
# It's useful to run some subset of the tests on the older version of Ubuntu
# so that we don't spend too many resources on this but can find major issues quickly
# while we're in a situation where we support two versions. This section may be commented
# out when not in use to easily add/drop future support for any given major dependency.
#
# exclude:
# - mongo-version: "4.4"
# include:
# - shard_name: "xmodule-with-cms"
# python-version: "3.11"
# django-version: "pinned"
# mongo-version: "4.4"
# - shard_name: "xmodule-with-lms"
# python-version: "3.11"
# django-version: "pinned"
# mongo-version: "4.4"
# We're testing the older version of Ubuntu and running the xmodule tests since those rely on many
# dependent complex libraries and will hopefully catch most issues quickly.
include:
- shard_name: "xmodule-with-cms"
python-version: "3.11"
django-version: "pinned"
mongo-version: "7.0"
os-version: "ubuntu-22.04"
- shard_name: "xmodule-with-lms"
python-version: "3.11"
django-version: "pinned"
mongo-version: "7.0"
os-version: "ubuntu-22.04"
steps:
- name: checkout repo
@@ -90,19 +95,10 @@ jobs:
activate = 1
EOF
- name: install mongo version
run: |
if [[ "${{ matrix.mongo-version }}" != "4.4" ]]; then
wget -qO - https://www.mongodb.org/static/pgp/server-${{ matrix.mongo-version }}.asc | sudo apt-key add -
echo "deb https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/${{ matrix.mongo-version }} multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-${{ matrix.mongo-version }}.list
sudo apt-get update && sudo apt-get install -y mongodb-org="${{ matrix.mongo-version }}.*"
fi
- name: start mongod server for tests
run: |
sudo mkdir -p /data/db
sudo chmod -R a+rw /data/db
mongod &
- name: Start MongoDB
uses: supercharge/mongodb-github-action@1.11.0
with:
mongodb-version: ${{ matrix.mongo-version }}
- name: Setup Python
uses: actions/setup-python@v5
@@ -164,7 +160,7 @@ jobs:
overwrite: true
collect-and-verify:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup Python
@@ -229,7 +225,7 @@ jobs:
# https://github.com/orgs/community/discussions/33579
success:
name: Unit tests successful
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: always()
needs: [run-tests]
steps:
@@ -240,7 +236,7 @@ jobs:
jobs: ${{ toJSON(needs) }}
compile-warnings-report:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs: [run-tests]
steps:
- uses: actions/checkout@v4
@@ -268,7 +264,7 @@ jobs:
overwrite: true
merge-artifacts:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs: [compile-warnings-report]
steps:
- name: Merge Pytest Warnings JSON Artifacts
@@ -288,7 +284,7 @@ jobs:
# Combine and upload coverage reports.
coverage:
if: (github.repository == 'edx/edx-platform-private') || (github.repository == 'openedx/edx-platform' && (startsWith(github.base_ref, 'open-release') == false))
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs: [run-tests]
strategy:
matrix:

View File

@@ -1,4 +1,4 @@
name: CI
name: Verify Dunder __init__.py Files
on:
pull_request:

2
.nvmrc
View File

@@ -1 +1 @@
18
20

View File

@@ -3,7 +3,7 @@ version: 2
build:
os: "ubuntu-22.04"
tools:
python: "3.12"
python: "3.11"
sphinx:
configuration: docs/conf.py

View File

@@ -48,36 +48,36 @@ RUN echo "locales locales/locales_to_be_generated multiselect en_US.UTF-8 UTF-8"
# Setting up ppa deadsnakes to get python 3.11
RUN apt-get update && \
apt-get install -y software-properties-common && \
apt-add-repository -y ppa:deadsnakes/ppa
apt-get install -y software-properties-common && \
apt-add-repository -y ppa:deadsnakes/ppa
# Install requirements that are absolutely necessary
RUN apt-get update && \
apt-get -y dist-upgrade && \
apt-get -y install --no-install-recommends \
python3-pip \
python3.11 \
# python3-dev: required for building mysqlclient python package
python3.11-dev \
python3.11-venv \
libpython3.11 \
libpython3.11-stdlib \
libmysqlclient21 \
# libmysqlclient-dev: required for building mysqlclient python package
libmysqlclient-dev \
pkg-config \
libssl1.1 \
libxmlsec1-openssl \
# lynx: Required by https://github.com/openedx/edx-platform/blob/b489a4ecb122/openedx/core/lib/html_to_text.py#L16
lynx \
ntp \
git \
build-essential \
gettext \
gfortran \
graphviz \
locales \
swig \
python3-pip \
python3.11 \
# python3-dev: required for building mysqlclient python package
python3.11-dev \
python3.11-venv \
libpython3.11 \
libpython3.11-stdlib \
libmysqlclient21 \
# libmysqlclient-dev: required for building mysqlclient python package
libmysqlclient-dev \
pkg-config \
libssl1.1 \
libxmlsec1-openssl \
# lynx: Required by https://github.com/openedx/edx-platform/blob/b489a4ecb122/openedx/core/lib/html_to_text.py#L16
lynx \
ntp \
git \
build-essential \
gettext \
gfortran \
graphviz \
locales \
swig \
&& \
apt-get clean all && \
rm -rf /var/lib/apt/*
@@ -93,19 +93,19 @@ FROM minimal-system as builder-production
RUN apt-get update && \
apt-get -y install --no-install-recommends \
curl \
libssl-dev \
libffi-dev \
libfreetype6-dev \
libgeos-dev \
libgraphviz-dev \
libjpeg8-dev \
liblapack-dev \
libpng-dev \
libsqlite3-dev \
libxml2-dev \
libxmlsec1-dev \
libxslt1-dev
curl \
libssl-dev \
libffi-dev \
libfreetype6-dev \
libgeos-dev \
libgraphviz-dev \
libjpeg8-dev \
liblapack-dev \
libpng-dev \
libsqlite3-dev \
libxml2-dev \
libxmlsec1-dev \
libxslt1-dev
# Setup python virtual environment
# It is already 'activated' because $VIRTUAL_ENV/bin was put on $PATH
@@ -118,8 +118,8 @@ RUN pip install -r requirements/pip.txt
RUN pip install -r requirements/edx/base.txt
# Install node and npm
RUN nodeenv /edx/app/edxapp/nodeenv --node=18.19.0 --prebuilt
RUN npm install -g npm@10.5.x
RUN nodeenv /edx/app/edxapp/nodeenv --node=20.15.1 --prebuilt
RUN npm install -g npm@10.7.x
# This script is used by an npm post-install hook.
# We copy it into the image now so that it will be available when we run `npm install` in the next step.
@@ -178,8 +178,8 @@ FROM base as development
RUN apt-get update && \
apt-get -y install --no-install-recommends \
# wget is used in Makefile for common_constraints.txt
wget \
# wget is used in Makefile for common_constraints.txt
wget \
&& \
apt-get clean all && \
rm -rf /var/lib/apt/*

View File

@@ -70,6 +70,11 @@ complexity of Open edX configuration and deployment into their own hands.
System Dependencies
-------------------
OS:
* Ubuntu 22.04
* Ubuntu 24.04
Interperters/Tools:
* Python 3.11
@@ -100,6 +105,17 @@ Language Packages:
- ``pip install -r requirements/edx/base.txt`` (production)
- ``pip install -r requirements/edx/dev.txt`` (development)
Some Python packages have system dependencies. For example, installing these packages on Debian or Ubuntu will require first running ``sudo apt install python3-dev default-libmysqlclient-dev build-essential pkg-config`` to satisfy the requirements of the ``mysqlclient`` Python package.
Codejail Setup
--------------
As a part of the baremetal setup, you will need to configure your system to
work properly with codejail. See the `codejail installation steps`_ for more
details.
.. _codejail installation steps: https://github.com/openedx/codejail?tab=readme-ov-file#installation
Build Steps
-----------

View File

@@ -10,6 +10,8 @@ metadata:
- url: "https://docs.openedx.org"
title: "Documentation"
icon: "Web"
annotations:
openedx.org/release: "master"
spec:
owner: group:wg-maintenance-edx-platform
type: 'service'

View File

@@ -25,6 +25,7 @@ from common.djangoapps.util.date_utils import get_default_time_display
from common.djangoapps.util.json_request import JsonResponse
from openedx.core.djangoapps.contentserver.caching import del_cached_content
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx_filters.course_authoring.filters import LMSPageURLRequested
from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order
@@ -714,7 +715,15 @@ def get_asset_json(display_name, content_type, date, location, thumbnail_locatio
Helper method for formatting the asset information to send to client.
'''
asset_url = StaticContent.serialize_asset_key_with_slash(location)
external_url = urljoin(configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL), asset_url)
## .. filter_implemented_name: LMSPageURLRequested
## .. filter_type: org.openedx.course_authoring.lms.page.url.requested.v1
lms_root, _ = LMSPageURLRequested.run_filter(
url=configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL),
org=location.org,
)
external_url = urljoin(lms_root, asset_url)
portable_url = StaticContent.get_static_path_from_location(location)
usage_locations = [] if usage is None else usage
return {

View File

@@ -256,7 +256,8 @@ class SearchIndexerBase(metaclass=ABCMeta):
# Now index the content
for item in structure.get_children():
prepare_item_index(item, groups_usage_info=groups_usage_info)
searcher.index(items_index, request_timeout=timeout)
if items_index:
searcher.index(items_index, request_timeout=timeout)
cls.remove_deleted_items(searcher, structure_key, indexed_items)
except Exception as err: # pylint: disable=broad-except
# broad exception so that index operation does not prevent the rest of the application from working

View File

@@ -3,14 +3,17 @@ Helper methods for Studio views.
"""
from __future__ import annotations
import logging
import pathlib
import urllib
from lxml import etree
from mimetypes import guess_type
import re
from attrs import frozen, Factory
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _
from opaque_keys.edx.keys import AssetKey, CourseKey, UsageKey
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import DefinitionLocator, LocalId
from xblock.core import XBlock
from xblock.fields import ScopeIds
@@ -22,6 +25,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.xml_block import XmlMixin
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.lib.xblock.upstream_sync import UpstreamLink, UpstreamLinkException, fetch_customizable_fields
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
import openedx.core.djangoapps.content_staging.api as content_staging_api
import openedx.core.djangoapps.content_tagging.api as content_tagging_api
@@ -30,6 +34,10 @@ from .utils import reverse_course_url, reverse_library_url, reverse_usage_url
log = logging.getLogger(__name__)
User = get_user_model()
# Note: Grader types are used throughout the platform but most usages are simply in-line
# strings. In addition, new grader types can be defined on the fly anytime one is needed
# (because they're just strings). This dict is an attempt to constrain the sprawl in Studio.
@@ -184,6 +192,10 @@ def xblock_type_display_name(xblock, default_display_name=None):
# description like "Multiple Choice Problem", but that won't work if our 'block' argument is just the block_type
# string ("problem").
return _('Problem')
elif category == 'library_v2':
return _('Library Content')
elif category == 'itembank':
return _('Problem Bank')
component_class = XBlock.load_class(category)
if hasattr(component_class, 'display_name') and component_class.display_name.default:
return _(component_class.display_name.default) # lint-amnesty, pylint: disable=translation-of-non-string
@@ -261,7 +273,6 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
empty, and (2) a summary of changes made to static files in the destination
course.
"""
from cms.djangoapps.contentstore.views.preview import _load_preview_block
if not content_staging_api:
@@ -271,7 +282,6 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
# Clipboard is empty or expired/error/loading
return None, StaticFileNotices()
olx_str = content_staging_api.get_staged_content_olx(user_clipboard.content.id)
static_files = content_staging_api.get_staged_content_static_files(user_clipboard.content.id)
node = etree.fromstring(olx_str)
store = modulestore()
with store.bulk_operations(parent_key.course_key):
@@ -282,32 +292,102 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
node,
parent_xblock,
store,
user_id=request.user.id,
user=request.user,
slug_hint=user_clipboard.source_usage_key.block_id,
copied_from_block=str(user_clipboard.source_usage_key),
copied_from_version_num=user_clipboard.content.version_num,
tags=user_clipboard.content.tags,
)
# Now handle static files that need to go into Files & Uploads:
notices = _import_files_into_course(
course_key=parent_key.context_key,
staged_content_id=user_clipboard.content.id,
static_files=static_files,
)
# Now handle static files that need to go into Files & Uploads.
static_files = content_staging_api.get_staged_content_static_files(user_clipboard.content.id)
notices, substitutions = _import_files_into_course(
course_key=parent_key.context_key,
staged_content_id=user_clipboard.content.id,
static_files=static_files,
usage_key=new_xblock.scope_ids.usage_id,
)
# Rewrite the OLX's static asset references to point to the new
# locations for those assets. See _import_files_into_course for more
# info on why this is necessary.
if hasattr(new_xblock, 'data') and substitutions:
data_with_substitutions = new_xblock.data
for old_static_ref, new_static_ref in substitutions.items():
data_with_substitutions = data_with_substitutions.replace(
old_static_ref,
new_static_ref,
)
new_xblock.data = data_with_substitutions
store.update_item(new_xblock, request.user.id)
return new_xblock, notices
def _fetch_and_set_upstream_link(
copied_from_block: str,
copied_from_version_num: int,
temp_xblock: XBlock,
user: User
):
"""
Fetch and set upstream link for the given xblock. This function handles following cases:
* the xblock is copied from a v2 library; the library block is set as upstream.
* the xblock is copied from a course; no upstream is set, only copied_from_block is set.
* the xblock is copied from a course where the source block was imported from a library; the original libary block
is set as upstream.
"""
# Try to link the pasted block (downstream) to the copied block (upstream).
temp_xblock.upstream = copied_from_block
try:
UpstreamLink.get_for_block(temp_xblock)
except UpstreamLinkException:
# Usually this will fail. For example, if the copied block is a modulestore course block, it can't be an
# upstream. That's fine! Instead, we store a reference to where this block was copied from, in the
# 'copied_from_block' field (from AuthoringMixin).
# In case if the source block was imported from a library, we need to check its upstream
# and set the same upstream link for the new block.
source_descriptor = modulestore().get_item(UsageKey.from_string(copied_from_block))
if source_descriptor.upstream:
_fetch_and_set_upstream_link(
source_descriptor.upstream,
source_descriptor.upstream_version,
temp_xblock,
user,
)
else:
# else we store a reference to where this block was copied from, in the 'copied_from_block'
# field (from AuthoringMixin).
temp_xblock.upstream = None
temp_xblock.copied_from_block = copied_from_block
else:
# But if it doesn't fail, then populate the `upstream_version` field based on what was copied. Note that
# this could be the latest published version, or it could be an an even newer draft version.
temp_xblock.upstream_version = copied_from_version_num
# Also, fetch upstream values (`upstream_display_name`, etc.).
# Recall that the copied block could be a draft. So, rather than fetching from the published upstream (which
# could be older), fetch from the copied block itself. That way, if an author customizes a field, but then
# later wants to restore it, it will restore to the value that the field had when the block was pasted. Of
# course, if the author later syncs updates from a *future* published upstream version, then that will fetch
# new values from the published upstream content.
fetch_customizable_fields(upstream=temp_xblock, downstream=temp_xblock, user=user)
def _import_xml_node_to_parent(
node,
parent_xblock: XBlock,
# The modulestore we're using
store,
# The ID of the user who is performing this operation
user_id: int,
# The user who is performing this operation
user: User,
# Hint to use as usage ID (block_id) for the new XBlock
slug_hint: str | None = None,
# UsageKey of the XBlock that this one is a copy of
copied_from_block: str | None = None,
# Positive int version of source block, if applicable (e.g., library block).
# Zero if not applicable (e.g., course block).
copied_from_version_num: int = 0,
# Content tags applied to the source XBlock(s)
tags: dict[str, str] | None = None,
) -> XBlock:
@@ -315,6 +395,8 @@ def _import_xml_node_to_parent(
Given an XML node representing a serialized XBlock (OLX), import it into modulestore 'store' as a child of the
specified parent block. Recursively copy children as needed.
"""
# pylint: disable=too-many-statements
runtime = parent_xblock.runtime
parent_key = parent_xblock.scope_ids.usage_id
block_type = node.tag
@@ -366,6 +448,10 @@ def _import_xml_node_to_parent(
temp_xblock = xblock_class.parse_xml(node_without_children, runtime, keys)
child_nodes = list(node)
if issubclass(xblock_class, XmlMixin) and "x-is-pointer-node" in getattr(temp_xblock, "data", ""):
# Undo the "pointer node" hack if needed (e.g. for capa problems)
temp_xblock.data = re.sub(r'([^>]+) x-is-pointer-node="no"', r'\1', temp_xblock.data, count=1)
# Restore the original id_generator
runtime.id_generator = original_id_generator
@@ -373,19 +459,18 @@ def _import_xml_node_to_parent(
raise NotImplementedError("We don't yet support pasting XBlocks with children")
temp_xblock.parent = parent_key
if copied_from_block:
# Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin)
temp_xblock.copied_from_block = copied_from_block
_fetch_and_set_upstream_link(copied_from_block, copied_from_version_num, temp_xblock, user)
# Save the XBlock into modulestore. We need to save the block and its parent for this to work:
new_xblock = store.update_item(temp_xblock, user_id, allow_not_found=True)
new_xblock = store.update_item(temp_xblock, user.id, allow_not_found=True)
parent_xblock.children.append(new_xblock.location)
store.update_item(parent_xblock, user_id)
store.update_item(parent_xblock, user.id)
children_handled = False
if hasattr(new_xblock, 'studio_post_paste'):
# Allow an XBlock to do anything fancy it may need to when pasted from the clipboard.
# These blocks may handle their own children or parenting if needed. Let them return booleans to
# let us know if we need to handle these or not.
children_handed = new_xblock.studio_post_paste(store, node)
children_handled = new_xblock.studio_post_paste(store, node)
if not children_handled:
for child_node in child_nodes:
@@ -394,13 +479,20 @@ def _import_xml_node_to_parent(
child_node,
new_xblock,
store,
user_id=user_id,
user=user,
copied_from_block=str(child_copied_from),
tags=tags,
)
# Copy content tags to the new xblock
if copied_from_block and tags:
if new_xblock.upstream:
# If this block is synced from an upstream (e.g. library content),
# copy the tags from the upstream as ready-only
content_tagging_api.copy_tags_as_read_only(
new_xblock.upstream,
new_xblock.location,
)
elif copied_from_block and tags:
object_tags = tags.get(str(copied_from_block))
if object_tags:
content_tagging_api.set_all_object_tags(
@@ -415,11 +507,21 @@ def _import_files_into_course(
course_key: CourseKey,
staged_content_id: int,
static_files: list[content_staging_api.StagedContentFileData],
) -> StaticFileNotices:
usage_key: UsageKey,
) -> tuple[StaticFileNotices, dict[str, str]]:
"""
For the given staged static asset files (which are in "Staged Content" such as the user's clipbaord, but which
need to end up in the course's Files & Uploads page), import them into the destination course, unless they already
For the given staged static asset files (which are in "Staged Content" such
as the user's clipbaord, but which need to end up in the course's Files &
Uploads page), import them into the destination course, unless they already
exist.
This function returns a tuple of StaticFileNotices (assets added, errors,
conflicts), and static asset path substitutions that should be made in the
OLX in order to paste this content into this course. The latter is for the
case in which we're brining content in from a v2 library, which stores
static assets locally to a Component and needs to go into a subdirectory
when pasting into a course to avoid overwriting commonly named things, e.g.
"figure1.png".
"""
# List of files that were newly added to the destination course
new_files = []
@@ -427,17 +529,25 @@ def _import_files_into_course(
conflicting_files = []
# List of files that had an error (shouldn't happen unless we have some kind of bug)
error_files = []
# Store a mapping of asset URLs that need to be modified for the destination
# assets. This is necessary when you take something from a library and paste
# it into a course, because we need to translate Component-local static
# assets and shove them into the Course's global Files & Uploads space in a
# nested directory structure.
substitutions = {}
for file_data_obj in static_files:
if not isinstance(file_data_obj.source_key, AssetKey):
# This static asset was managed by the XBlock and instead of being added to "Files & Uploads", it is stored
# using some other system. We could make it available via runtime.resources_fs during XML parsing, but it's
# not needed here.
continue
# At this point, we know this is a "Files & Uploads" asset that we may need to copy into the course:
try:
result = _import_file_into_course(course_key, staged_content_id, file_data_obj)
result, substitution_for_file = _import_file_into_course(
course_key,
staged_content_id,
file_data_obj,
usage_key,
)
if result is True:
new_files.append(file_data_obj.filename)
substitutions.update(substitution_for_file)
elif result is None:
pass # This file already exists; no action needed.
else:
@@ -445,25 +555,45 @@ def _import_files_into_course(
except Exception: # lint-amnesty, pylint: disable=broad-except
error_files.append(file_data_obj.filename)
log.exception(f"Failed to import Files & Uploads file {file_data_obj.filename}")
return StaticFileNotices(
notices = StaticFileNotices(
new_files=new_files,
conflicting_files=conflicting_files,
error_files=error_files,
)
return notices, substitutions
def _import_file_into_course(
course_key: CourseKey,
staged_content_id: int,
file_data_obj: content_staging_api.StagedContentFileData,
) -> bool | None:
usage_key: UsageKey,
) -> tuple[bool | None, dict]:
"""
Import a single staged static asset file into the course, unless it already exists.
Returns True if it was imported, False if there's a conflict, or None if
the file already existed (no action needed).
"""
filename = file_data_obj.filename
new_key = course_key.make_asset_key("asset", filename)
clipboard_file_path = file_data_obj.filename
# We need to generate an AssetKey to add an asset to a course. The mapping
# of directories '/' -> '_' is a long-existing contentstore convention that
# we're not going to attempt to change.
if clipboard_file_path.startswith('static/'):
# If it's in this form, it came from a library and assumes component-local assets
file_path = clipboard_file_path.lstrip('static/')
import_path = f"components/{usage_key.block_type}/{usage_key.block_id}/{file_path}"
filename = pathlib.Path(file_path).name
new_key = course_key.make_asset_key("asset", import_path.replace("/", "_"))
else:
# Otherwise it came from a course...
file_path = clipboard_file_path
import_path = None
filename = pathlib.Path(file_path).name
new_key = course_key.make_asset_key("asset", file_path.replace("/", "_"))
try:
current_file = contentstore().find(new_key)
except NotFoundError:
@@ -471,22 +601,28 @@ def _import_file_into_course(
if not current_file:
# This static asset should be imported into the new course:
content_type = guess_type(filename)[0]
data = content_staging_api.get_staged_content_static_file_data(staged_content_id, filename)
data = content_staging_api.get_staged_content_static_file_data(staged_content_id, clipboard_file_path)
if data is None:
raise NotFoundError(file_data_obj.source_key)
content = StaticContent(new_key, name=filename, content_type=content_type, data=data)
content = StaticContent(
new_key,
name=filename,
content_type=content_type,
data=data,
import_path=import_path
)
# If it's an image file, also generate the thumbnail:
thumbnail_content, thumbnail_location = contentstore().generate_thumbnail(content)
if thumbnail_content is not None:
content.thumbnail_location = thumbnail_location
contentstore().save(content)
return True
return True, {clipboard_file_path: f"static/{import_path}"}
elif current_file.content_digest == file_data_obj.md5_hash:
# The file already exists and matches exactly, so no action is needed
return None
return None, {}
else:
# There is a conflict with some other file that has the same name.
return False
return False, {}
def is_item_in_course_tree(item):

View File

@@ -8,7 +8,7 @@ from django.core.management.base import BaseCommand
from openedx.core.djangoapps.django_comment_common.utils import are_permissions_roles_seeded, seed_permissions_roles
from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import SignalHandler, modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.xml_importer import import_course_from_xml # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.util.sandboxing import DEFAULT_PYTHON_LIB_FILENAME # lint-amnesty, pylint: disable=wrong-import-order
@@ -73,6 +73,10 @@ class Command(BaseCommand):
for course in course_items:
course_id = course.id
# Importing is an act of publishing so send the course published signal.
SignalHandler.course_published.send_robust(sender=self, course_key=course_id)
# Seed forum permission roles if we need to.
if not are_permissions_roles_seeded(course_id):
self.stdout.write(f'Seeding forum roles for course {course_id}\n')
seed_permissions_roles(course_id)

View File

@@ -4,9 +4,10 @@
import logging
from textwrap import dedent
from time import time
from datetime import date
from datetime import date, datetime
from django.core.management import BaseCommand, CommandError
from django.conf import settings
from elasticsearch import exceptions
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
@@ -42,6 +43,10 @@ class Command(BaseCommand):
parser.add_argument('--active',
action='store_true',
help='Reindex active courses only')
parser.add_argument('--from_inclusion_date',
action='store_true',
help='Reindex courses with a start date greater than COURSEWARE_SEARCH_INCLUSION_DATE'
)
parser.add_argument('--setup',
action='store_true',
help='Reindex all courses on developers stack setup')
@@ -70,15 +75,17 @@ class Command(BaseCommand):
course_ids = options['course_ids']
all_option = options['all']
active_option = options['active']
inclusion_date_option = options['from_inclusion_date']
setup_option = options['setup']
readable_option = options['warning']
index_all_courses_option = all_option or setup_option
if ((not course_ids and not (index_all_courses_option or active_option)) or
(course_ids and (index_all_courses_option or active_option))):
course_option_flag_option = index_all_courses_option or active_option or inclusion_date_option
if (not course_ids and not course_option_flag_option) or (course_ids and course_option_flag_option):
raise CommandError((
"reindex_course requires one or more <course_id>s"
" OR the --all, --active or --setup flags."
" OR the --all, --active, --setup, or --from_inclusion_date flags."
))
store = modulestore()
@@ -97,14 +104,16 @@ class Command(BaseCommand):
logging.exception('Search Engine error - %s', exc)
return
index_exists = searcher._es.indices.exists(index=index_name) # pylint: disable=protected-access
# Legacy Elasticsearch engine
if hasattr(searcher, '_es'): # pylint: disable=protected-access
index_exists = searcher._es.indices.exists(index=index_name) # pylint: disable=protected-access
index_mapping = searcher._es.indices.get_mapping( # pylint: disable=protected-access
index=index_name,
) if index_exists else {}
index_mapping = searcher._es.indices.get_mapping( # pylint: disable=protected-access
index=index_name,
) if index_exists else {}
if index_exists and index_mapping:
return
if index_exists and index_mapping:
return
# if reindexing is done during devstack setup step, don't prompt the user
if setup_option or query_yes_no(self.CONFIRMATION_PROMPT, default="no"):
@@ -127,6 +136,20 @@ class Command(BaseCommand):
course_keys = list(map(lambda course: course.id, active_courses))
logging.warning(f'Selected {len(course_keys)} active courses over a total of {len(all_courses)}.')
elif inclusion_date_option:
# in case of --from_inclusion_date, we get the list of course keys from all courses
# that are stored in modulestore and filter out courses with a start date less than
# the settings defined COURSEWARE_SEARCH_INCLUSION_DATE
all_courses = modulestore().get_courses()
inclusion_date = datetime.strptime(
settings.FEATURES.get('COURSEWARE_SEARCH_INCLUSION_DATE', '2020-01-01'),
'%Y-%m-%d'
)
# We keep the courses that has a start date and the start date is greater than the inclusion date
active_courses = filter(lambda course: course.start and (course.start >= inclusion_date), all_courses)
course_keys = list(map(lambda course: course.id, active_courses))
else:
# in case course keys are provided as arguments

View File

@@ -40,6 +40,9 @@ class TestReindexCourse(ModuleStoreTestCase):
self.third_course = CourseFactory.create(
org="test", course="course3", display_name="run1", start=None, end=None
)
self.fourth_course = CourseFactory.create(
org="test", course="course4", display_name="run1", start=datetime.min.today() - timedelta(weeks=60)
)
REINDEX_PATH_LOCATION = (
'cms.djangoapps.contentstore.management.commands.reindex_course.CoursewareSearchIndexer.do_course_reindex'
@@ -111,7 +114,9 @@ class TestReindexCourse(ModuleStoreTestCase):
call_command('reindex_course', all=True)
patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no')
expected_calls = self._build_calls(self.first_course, self.second_course, self.third_course)
expected_calls = self._build_calls(
self.first_course, self.second_course, self.third_course, self.fourth_course
)
self.assertCountEqual(patched_index.mock_calls, expected_calls)
def test_given_all_key_prompts_and_reindexes_all_courses_cancelled(self):
@@ -134,5 +139,21 @@ class TestReindexCourse(ModuleStoreTestCase):
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
call_command('reindex_course', active=True)
expected_calls = self._build_calls(self.first_course)
expected_calls = self._build_calls(self.first_course, self.fourth_course)
self.assertCountEqual(patched_index.mock_calls, expected_calls)
@mock.patch.dict(
'django.conf.settings.FEATURES',
{'COURSEWARE_SEARCH_INCLUSION_DATE': (datetime.min.today() - timedelta(weeks=52)).strftime('%Y-%m-%d')}
)
def test_given_from_inclusion_date_key_prompt(self):
"""
Test that reindexes all courses that have a start date after a defined inclusion date
when --from_inclusion_date key is given.
"""
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
call_command('reindex_course', from_inclusion_date=True)
expected_calls = self._build_calls(self.first_course, self.second_course)
self.assertCountEqual(patched_index.mock_calls, expected_calls)

View File

@@ -6,9 +6,10 @@ from .course_details import CourseDetailsSerializer
from .course_index import CourseIndexSerializer
from .course_rerun import CourseRerunSerializer
from .course_team import CourseTeamSerializer
from .course_waffle_flags import CourseWaffleFlagsSerializer
from .grading import CourseGradingModelSerializer, CourseGradingSerializer
from .group_configurations import CourseGroupConfigurationsSerializer
from .home import CourseHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer
from .home import StudioHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer
from .proctoring import (
LimitedProctoredExamSettingsSerializer,
ProctoredExamConfigurationSerializer,

View File

@@ -0,0 +1,154 @@
"""
API Serializers for course waffle flags
"""
from rest_framework import serializers
from cms.djangoapps.contentstore import toggles
class CourseWaffleFlagsSerializer(serializers.Serializer):
"""
Serializer for course waffle flags
"""
use_new_home_page = serializers.SerializerMethodField()
use_new_custom_pages = serializers.SerializerMethodField()
use_new_schedule_details_page = serializers.SerializerMethodField()
use_new_advanced_settings_page = serializers.SerializerMethodField()
use_new_grading_page = serializers.SerializerMethodField()
use_new_updates_page = serializers.SerializerMethodField()
use_new_import_page = serializers.SerializerMethodField()
use_new_export_page = serializers.SerializerMethodField()
use_new_files_uploads_page = serializers.SerializerMethodField()
use_new_video_uploads_page = serializers.SerializerMethodField()
use_new_course_outline_page = serializers.SerializerMethodField()
use_new_unit_page = serializers.SerializerMethodField()
use_new_course_team_page = serializers.SerializerMethodField()
use_new_certificates_page = serializers.SerializerMethodField()
use_new_textbooks_page = serializers.SerializerMethodField()
use_new_group_configurations_page = serializers.SerializerMethodField()
enable_course_optimizer = serializers.SerializerMethodField()
def get_course_key(self):
"""
Retrieve the course_key from the context
"""
return self.context.get("course_key")
def get_use_new_home_page(self, obj):
"""
Method to get the use_new_home_page switch
"""
return toggles.use_new_home_page()
def get_use_new_custom_pages(self, obj):
"""
Method to get the use_new_custom_pages switch
"""
course_key = self.get_course_key()
return toggles.use_new_custom_pages(course_key)
def get_use_new_schedule_details_page(self, obj):
"""
Method to get the use_new_schedule_details_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_schedule_details_page(course_key)
def get_use_new_advanced_settings_page(self, obj):
"""
Method to get the use_new_advanced_settings_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_advanced_settings_page(course_key)
def get_use_new_grading_page(self, obj):
"""
Method to get the use_new_grading_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_grading_page(course_key)
def get_use_new_updates_page(self, obj):
"""
Method to get the use_new_updates_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_updates_page(course_key)
def get_use_new_import_page(self, obj):
"""
Method to get the use_new_import_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_import_page(course_key)
def get_use_new_export_page(self, obj):
"""
Method to get the use_new_export_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_export_page(course_key)
def get_use_new_files_uploads_page(self, obj):
"""
Method to get the use_new_files_uploads_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_files_uploads_page(course_key)
def get_use_new_video_uploads_page(self, obj):
"""
Method to get the use_new_video_uploads_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_video_uploads_page(course_key)
def get_use_new_course_outline_page(self, obj):
"""
Method to get the use_new_course_outline_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_course_outline_page(course_key)
def get_use_new_unit_page(self, obj):
"""
Method to get the use_new_unit_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_unit_page(course_key)
def get_use_new_course_team_page(self, obj):
"""
Method to get the use_new_course_team_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_course_team_page(course_key)
def get_use_new_certificates_page(self, obj):
"""
Method to get the use_new_certificates_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_certificates_page(course_key)
def get_use_new_textbooks_page(self, obj):
"""
Method to get the use_new_textbooks_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_textbooks_page(course_key)
def get_use_new_group_configurations_page(self, obj):
"""
Method to get the use_new_group_configurations_page switch
"""
course_key = self.get_course_key()
return toggles.use_new_group_configurations_page(course_key)
def get_enable_course_optimizer(self, obj):
"""
Method to get the enable_course_optimizer waffle flag
"""
course_key = self.get_course_key()
return toggles.enable_course_optimizer(course_key)

View File

@@ -41,8 +41,8 @@ class LibraryTabSerializer(serializers.Serializer):
libraries = LibraryViewSerializer(many=True, required=False, allow_null=True)
class CourseHomeSerializer(serializers.Serializer):
"""Serializer for course home"""
class StudioHomeSerializer(serializers.Serializer):
"""Serializer for Studio home"""
allow_course_reruns = serializers.BooleanField()
allow_to_create_new_org = serializers.BooleanField()
allow_unicode_course_id = serializers.BooleanField()
@@ -58,12 +58,15 @@ class CourseHomeSerializer(serializers.Serializer):
in_process_course_actions = UnsucceededCourseSerializer(many=True, required=False, allow_null=True)
libraries = LibraryViewSerializer(many=True, required=False, allow_null=True)
libraries_enabled = serializers.BooleanField()
libraries_v1_enabled = serializers.BooleanField()
libraries_v2_enabled = serializers.BooleanField()
taxonomies_enabled = serializers.BooleanField()
taxonomy_list_mfe_url = serializers.CharField()
optimization_enabled = serializers.BooleanField()
request_course_creator_url = serializers.CharField()
rerun_creator_status = serializers.BooleanField()
show_new_library_button = serializers.BooleanField()
show_new_library_v2_button = serializers.BooleanField()
split_studio_home = serializers.BooleanField()
studio_name = serializers.CharField()
studio_short_name = serializers.CharField()

View File

@@ -89,6 +89,7 @@ class ContainerHandlerSerializer(serializers.Serializer):
unit_block_id = serializers.CharField(source="unit.location.block_id")
subsection_location = serializers.CharField(source="subsection.location")
course_sequence_ids = serializers.ListField(child=serializers.CharField())
library_content_picker_url = serializers.CharField()
def get_assets_url(self, obj):
"""
@@ -103,6 +104,18 @@ class ContainerHandlerSerializer(serializers.Serializer):
return None
class UpstreamLinkSerializer(serializers.Serializer):
"""
Serializer holding info for syncing a block with its upstream (eg, a library block).
"""
upstream_ref = serializers.CharField()
version_synced = serializers.IntegerField()
version_available = serializers.IntegerField(allow_null=True)
version_declined = serializers.IntegerField(allow_null=True)
error_message = serializers.CharField(allow_null=True)
ready_to_sync = serializers.BooleanField()
class ChildVerticalContainerSerializer(serializers.Serializer):
"""
Serializer for representing a xblock child of vertical container.
@@ -113,6 +126,7 @@ class ChildVerticalContainerSerializer(serializers.Serializer):
block_type = serializers.CharField()
user_partition_info = serializers.DictField()
user_partitions = serializers.ListField()
upstream_link = UpstreamLinkSerializer(allow_null=True)
actions = serializers.SerializerMethodField()
validation_messages = MessageValidation(many=True)
render_error = serializers.CharField()

View File

@@ -17,6 +17,7 @@ from .views import (
CourseRerunView,
CourseSettingsView,
CourseVideosView,
CourseWaffleFlagsView,
HomePageView,
HomePageCoursesView,
HomePageLibrariesView,
@@ -131,6 +132,11 @@ urlpatterns = [
VerticalContainerView.as_view(),
name="container_vertical"
),
re_path(
fr'^course_waffle_flags(?:/{COURSE_ID_PATTERN})?$',
CourseWaffleFlagsView.as_view(),
name="course_waffle_flags"
),
# Authoring API
# Do not use under v1 yet (Nov. 23). The Authoring API is still experimental and the v0 versions should be used

View File

@@ -5,6 +5,7 @@ from .certificates import CourseCertificatesView
from .course_details import CourseDetailsView
from .course_index import CourseIndexView
from .course_rerun import CourseRerunView
from .course_waffle_flags import CourseWaffleFlagsView
from .course_team import CourseTeamView
from .grading import CourseGradingView
from .group_configurations import CourseGroupConfigurationsView

View File

@@ -0,0 +1,73 @@
""" API Views for course waffle flags """
from opaque_keys.edx.keys import CourseKey
from rest_framework.decorators import APIView
from rest_framework.response import Response
from openedx.core.lib.api.view_utils import view_auth_classes
from ..serializers import CourseWaffleFlagsSerializer
@view_auth_classes(is_authenticated=True)
class CourseWaffleFlagsView(APIView):
"""
API view to retrieve course waffle flag settings for a specific course.
This view provides a GET endpoint that returns the status of various waffle
flags for a given course. It requires the user to be authenticated.
"""
def get(self, request, course_id=None):
"""
Retrieve the waffle flag settings for the specified course.
Args:
request (HttpRequest): The HTTP request object.
course_id (str, optional): The ID of the course for which to retrieve
the waffle flag settings. If not provided,
defaults to None.
Returns:
Response: A JSON response containing the status of various waffle flags
for the specified course.
**Example Request**
GET /api/contentstore/v1/course_waffle_flags
GET /api/contentstore/v1/course_waffle_flags/course-v1:test+test+test
**Response Values**
A JSON response containing the status of various waffle flags
for the specified course.
**Example Response**
```json
{
"use_new_home_page": true,
"use_new_custom_pages": true,
"use_new_schedule_details_page": true,
"use_new_advanced_settings_page": true,
"use_new_grading_page": true,
"use_new_updates_page": true,
"use_new_import_page": true,
"use_new_export_page": true,
"use_new_files_uploads_page": true,
"use_new_video_uploads_page": false,
"use_new_course_outline_page": true,
"use_new_unit_page": false,
"use_new_course_team_page": true,
"use_new_certificates_page": true,
"use_new_textbooks_page": true,
"use_new_group_configurations_page": true
}
```
"""
course_key = CourseKey.from_string(course_id) if course_id else None
serializer = CourseWaffleFlagsSerializer(
context={"course_key": course_key}, data={}
)
serializer.is_valid(raise_exception=True)
return Response(serializer.data)

View File

@@ -8,7 +8,7 @@ from rest_framework.views import APIView
from openedx.core.lib.api.view_utils import view_auth_classes
from ....utils import get_home_context, get_course_context, get_library_context
from ..serializers import CourseHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer
from ..serializers import StudioHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer
@view_auth_classes(is_authenticated=True)
@@ -24,7 +24,7 @@ class HomePageView(APIView):
description="Query param to filter by course org",
)],
responses={
200: CourseHomeSerializer,
200: StudioHomeSerializer,
401: "The requester is not authenticated.",
},
)
@@ -59,10 +59,14 @@ class HomePageView(APIView):
"in_process_course_actions": [],
"libraries": [],
"libraries_enabled": true,
"libraries_v1_enabled": true,
"libraries_v2_enabled": true,
"library_authoring_mfe_url": "//localhost:3001/course/course-v1:edX+P315+2T2023",
"optimization_enabled": true,
"request_course_creator_url": "/request_course_creator",
"rerun_creator_status": true,
"show_new_library_button": true,
"show_new_library_v2_button": true,
"split_studio_home": false,
"studio_name": "Studio",
"studio_short_name": "Studio",
@@ -84,7 +88,7 @@ class HomePageView(APIView):
'platform_name': settings.PLATFORM_NAME,
'user_is_active': request.user.is_active,
})
serializer = CourseHomeSerializer(home_context)
serializer = StudioHomeSerializer(home_context)
return Response(serializer.data)

View File

@@ -0,0 +1,129 @@
"""
Unit tests for the course waffle flags view
"""
from django.contrib.auth import get_user_model
from django.urls import reverse
from rest_framework import status
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel
User = get_user_model()
class CourseWaffleFlagsViewTest(CourseTestCase):
"""
Tests for the CourseWaffleFlagsView endpoint, which returns waffle flag states
for a specific course or globally if no course ID is provided.
"""
course_waffle_flags = [
"use_new_custom_pages",
"use_new_schedule_details_page",
"use_new_advanced_settings_page",
"use_new_grading_page",
"use_new_updates_page",
"use_new_import_page",
"use_new_export_page",
"use_new_files_uploads_page",
"use_new_video_uploads_page",
"use_new_course_outline_page",
"use_new_unit_page",
"use_new_course_team_page",
"use_new_certificates_page",
"use_new_textbooks_page",
"use_new_group_configurations_page",
]
other_expected_waffle_flags = ["enable_course_optimizer"]
def setUp(self):
"""
Set up test data and state before each test method.
This method initializes the endpoint URL and creates a set of waffle flags
for the test course, setting each flag's value to `True`.
"""
super().setUp()
self.url = reverse("cms.djangoapps.contentstore:v1:course_waffle_flags")
self.create_waffle_flags(self.course_waffle_flags)
self.create_custom_waffle_flags()
def create_custom_waffle_flags(self, enabled=True):
"""
Helper method to create waffle flags that are not part of `course_waffle_flags` and have
a different format.
"""
WaffleFlagCourseOverrideModel.objects.create(
waffle_flag="contentstore.enable_course_optimizer",
course_id=self.course.id,
enabled=enabled,
)
def create_waffle_flags(self, flags, enabled=True):
"""
Helper method to create waffle flag entries in the database for the test course.
Args:
flags (list): A list of flag names to set up.
enabled (bool): The value to set for each flag's enabled state.
"""
for flag in flags:
WaffleFlagCourseOverrideModel.objects.create(
waffle_flag=f"contentstore.new_studio_mfe.{flag}",
course_id=self.course.id,
enabled=enabled,
)
def expected_response(self, enabled=False):
"""
Generate an expected response dictionary based on the enabled flag.
Args:
enabled (bool): State to assign to each waffle flag in the response.
Returns:
dict: A dictionary with each flag set to the value of `enabled`.
"""
res = {flag: enabled for flag in self.course_waffle_flags}
for flag in self.other_expected_waffle_flags:
res[flag] = enabled
return res
def test_get_course_waffle_flags_with_course_id(self):
"""
Test that waffle flags for a specific course are correctly returned when
a valid course ID is provided.
Expected Behavior:
- The response should return HTTP 200 status.
- Each flag returned should be `True` as set up in the `setUp` method.
"""
course_url = reverse(
"cms.djangoapps.contentstore:v1:course_waffle_flags",
kwargs={"course_id": self.course.id},
)
expected_response = self.expected_response(enabled=True)
expected_response["use_new_home_page"] = False
response = self.client.get(course_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertDictEqual(expected_response, response.data)
def test_get_course_waffle_flags_without_course_id(self):
"""
Test that the default waffle flag states are returned when no course ID is provided.
Expected Behavior:
- The response should return HTTP 200 status.
- Each flag returned should default to `False`, representing the global
default state for each flag.
"""
expected_response = self.expected_response(enabled=False)
expected_response["use_new_home_page"] = False
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertDictEqual(expected_response, response.data)

View File

@@ -2,7 +2,9 @@
Unit tests for home page view.
"""
import ddt
import pytz
from collections import OrderedDict
from datetime import datetime, timedelta
from django.conf import settings
from django.test import override_settings
from django.urls import reverse
@@ -33,12 +35,7 @@ class HomePageViewTest(CourseTestCase):
def setUp(self):
super().setUp()
self.url = reverse("cms.djangoapps.contentstore:v1:home")
def test_home_page_courses_response(self):
"""Check successful response content"""
response = self.client.get(self.url)
expected_response = {
self.expected_response = {
"allow_course_reruns": True,
"allow_to_create_new_org": False,
"allow_unicode_course_id": False,
@@ -51,12 +48,15 @@ class HomePageViewTest(CourseTestCase):
"in_process_course_actions": [],
"libraries": [],
"libraries_enabled": True,
"libraries_v1_enabled": True,
"libraries_v2_enabled": False,
"taxonomies_enabled": True,
"taxonomy_list_mfe_url": 'http://course-authoring-mfe/taxonomies',
"optimization_enabled": False,
"request_course_creator_url": "/request_course_creator",
"rerun_creator_status": True,
"show_new_library_button": True,
"show_new_library_v2_button": True,
"split_studio_home": False,
"studio_name": settings.STUDIO_NAME,
"studio_short_name": settings.STUDIO_SHORT_NAME,
@@ -66,6 +66,21 @@ class HomePageViewTest(CourseTestCase):
"user_is_active": True,
}
def test_home_page_studio_response(self):
"""Check successful response content"""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertDictEqual(self.expected_response, response.data)
@override_settings(MEILISEARCH_ENABLED=True)
def test_home_page_studio_with_meilisearch_enabled(self):
"""Check response content when Meilisearch is enabled"""
response = self.client.get(self.url)
expected_response = self.expected_response
expected_response["libraries_v2_enabled"] = True
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertDictEqual(expected_response, response.data)
@@ -88,12 +103,13 @@ class HomePageCoursesViewTest(CourseTestCase):
def setUp(self):
super().setUp()
self.url = reverse("cms.djangoapps.contentstore:v1:courses")
CourseOverviewFactory.create(
self.course_overview = CourseOverviewFactory.create(
id=self.course.id,
org=self.course.org,
display_name=self.course.display_name,
display_number_with_default=self.course.number,
)
self.non_staff_client, _ = self.create_non_staff_authed_user_client()
def test_home_page_response(self):
"""Check successful response content"""
@@ -105,7 +121,7 @@ class HomePageCoursesViewTest(CourseTestCase):
"courses": [{
"course_key": course_id,
"display_name": self.course.display_name,
"lms_link": f'//{settings.LMS_BASE}/courses/{course_id}/jump_to/{self.course.location}',
"lms_link": f'{settings.LMS_ROOT_URL}/courses/{course_id}/jump_to/{self.course.location}',
"number": self.course.number,
"org": self.course.org,
"rerun_link": f'/course_rerun/{course_id}',
@@ -119,6 +135,7 @@ class HomePageCoursesViewTest(CourseTestCase):
print(response.data)
self.assertDictEqual(expected_response, response.data)
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
def test_home_page_response_with_api_v2(self):
"""Check successful response content with api v2 modifications.
@@ -132,7 +149,7 @@ class HomePageCoursesViewTest(CourseTestCase):
OrderedDict([
("course_key", course_id),
("display_name", self.course.display_name),
("lms_link", f'//{settings.LMS_BASE}/courses/{course_id}/jump_to/{self.course.location}'),
("lms_link", f'{settings.LMS_ROOT_URL}/courses/{course_id}/jump_to/{self.course.location}'),
("number", self.course.number),
("org", self.course.org),
("rerun_link", f'/course_rerun/{course_id}'),
@@ -143,12 +160,88 @@ class HomePageCoursesViewTest(CourseTestCase):
"in_process_course_actions": [],
}
with override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API):
response = self.client.get(self.url)
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertDictEqual(expected_response, response.data)
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
@ddt.data(
("active_only", "true", 2, 0),
("archived_only", "true", 0, 1),
("search", "sample", 1, 0),
("search", "demo", 0, 1),
("order", "org", 2, 1),
("order", "display_name", 2, 1),
("order", "number", 2, 1),
("order", "run", 2, 1)
)
@ddt.unpack
def test_filter_and_ordering_courses(
self,
filter_key,
filter_value,
expected_active_length,
expected_archived_length
):
"""Test home page with org filter and ordering for a staff user.
The test creates an active/archived course, and then filters/orders them using the query parameters.
"""
archived_course_key = self.store.make_course_key("demo-org", "demo-number", "demo-run")
CourseOverviewFactory.create(
display_name="Course (Demo)",
id=archived_course_key,
org=archived_course_key.org,
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
)
active_course_key = self.store.make_course_key("sample-org", "sample-number", "sample-run")
CourseOverviewFactory.create(
display_name="Course (Sample)",
id=active_course_key,
org=active_course_key.org,
)
response = self.client.get(self.url, {filter_key: filter_value})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data["archived_courses"]), expected_archived_length)
self.assertEqual(len(response.data["courses"]), expected_active_length)
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
@ddt.data(
("active_only", "true"),
("archived_only", "true"),
("search", "sample"),
("order", "org"),
)
@ddt.unpack
def test_filter_and_ordering_no_courses_staff(self, filter_key, filter_value):
"""Test home page with org filter and ordering when there are no courses for a staff user."""
self.course_overview.delete()
response = self.client.get(self.url, {filter_key: filter_value})
self.assertEqual(len(response.data["courses"]), 0)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
@ddt.data(
("active_only", "true"),
("archived_only", "true"),
("search", "sample"),
("order", "org"),
)
@ddt.unpack
def test_home_page_response_no_courses_non_staff(self, filter_key, filter_value):
"""Test home page with org filter and ordering when there are no courses for a non-staff user."""
self.course_overview.delete()
response = self.non_staff_client.get(self.url)
self.assertEqual(len(response.data["courses"]), 0)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True)
def test_org_query_if_passed(self):
"""Test home page when org filter passed as a query param"""

View File

@@ -70,6 +70,8 @@ class BaseXBlockContainer(CourseTestCase):
parent=self.vertical.location,
category="html",
display_name="Html Content 2",
upstream="lb:FakeOrg:FakeLib:html:FakeBlock",
upstream_version=5,
)
def create_block(self, parent, category, display_name, **kwargs):
@@ -193,6 +195,7 @@ class ContainerVerticalViewTest(BaseXBlockContainer):
"name": self.html_unit_first.display_name_with_default,
"block_id": str(self.html_unit_first.location),
"block_type": self.html_unit_first.location.block_type,
"upstream_link": None,
"user_partition_info": expected_user_partition_info,
"user_partitions": expected_user_partitions,
"actions": {
@@ -218,12 +221,21 @@ class ContainerVerticalViewTest(BaseXBlockContainer):
"can_delete": True,
"can_manage_tags": True,
},
"upstream_link": {
"upstream_ref": "lb:FakeOrg:FakeLib:html:FakeBlock",
"version_synced": 5,
"version_available": None,
"version_declined": None,
"error_message": "Linked library item was not found in the system",
"ready_to_sync": False,
},
"user_partition_info": expected_user_partition_info,
"user_partitions": expected_user_partitions,
"validation_messages": [],
"render_error": "",
},
]
self.maxDiff = None
self.assertEqual(response.data["children"], expected_response)
def test_not_valid_usage_key_string(self):

View File

@@ -20,6 +20,7 @@ from cms.djangoapps.contentstore.rest_api.v1.serializers import (
ContainerHandlerSerializer,
VerticalContainerSerializer,
)
from cms.lib.xblock.upstream_sync import UpstreamLink
from openedx.core.lib.api.view_utils import view_auth_classes
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
@@ -198,6 +199,7 @@ class VerticalContainerView(APIView, ContainerHandlerMixin):
"block_type": "drag-and-drop-v2",
"user_partition_info": {},
"user_partitions": {}
"upstream_link": null,
"actions": {
"can_copy": true,
"can_duplicate": true,
@@ -215,6 +217,13 @@ class VerticalContainerView(APIView, ContainerHandlerMixin):
"block_type": "video",
"user_partition_info": {},
"user_partitions": {}
"upstream_link": {
"upstream_ref": "lb:org:mylib:video:404",
"version_synced": 16
"version_available": null,
"error_message": "Linked library item not found: lb:org:mylib:video:404",
"ready_to_sync": false,
},
"actions": {
"can_copy": true,
"can_duplicate": true,
@@ -232,6 +241,13 @@ class VerticalContainerView(APIView, ContainerHandlerMixin):
"block_type": "html",
"user_partition_info": {},
"user_partitions": {},
"upstream_link": {
"upstream_ref": "lb:org:mylib:html:abcd",
"version_synced": 43,
"version_available": 49,
"error_message": null,
"ready_to_sync": true,
},
"actions": {
"can_copy": true,
"can_duplicate": true,
@@ -267,6 +283,7 @@ class VerticalContainerView(APIView, ContainerHandlerMixin):
child_info = modulestore().get_item(child)
user_partition_info = get_visibility_partition_info(child_info, course=course)
user_partitions = get_user_partition_info(child_info, course=course)
upstream_link = UpstreamLink.try_get_for_block(child_info)
validation_messages = get_xblock_validation_messages(child_info)
render_error = get_xblock_render_error(request, child_info)
@@ -277,6 +294,12 @@ class VerticalContainerView(APIView, ContainerHandlerMixin):
"block_type": child_info.location.block_type,
"user_partition_info": user_partition_info,
"user_partitions": user_partitions,
"upstream_link": (
# If the block isn't linked to an upstream (which is by far the most common case) then just
# make this field null, which communicates the same info, but with less noise.
upstream_link.to_json() if upstream_link.upstream_ref
else None
),
"validation_messages": validation_messages,
"render_error": render_error,
})

View File

@@ -1,15 +1,31 @@
"""Contenstore API v2 URLs."""
from django.urls import path
from cms.djangoapps.contentstore.rest_api.v2.views import HomePageCoursesViewV2
from django.conf import settings
from django.urls import path, re_path
from cms.djangoapps.contentstore.rest_api.v2.views import home, downstreams
app_name = "v2"
urlpatterns = [
path(
"home/courses",
HomePageCoursesViewV2.as_view(),
home.HomePageCoursesViewV2.as_view(),
name="courses",
),
# TODO: Potential future path.
# re_path(
# fr'^downstreams/$',
# downstreams.DownstreamsListView.as_view(),
# name="downstreams_list",
# ),
re_path(
fr'^downstreams/{settings.USAGE_KEY_PATTERN}$',
downstreams.DownstreamView.as_view(),
name="downstream"
),
re_path(
fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$',
downstreams.SyncFromUpstreamView.as_view(),
name="sync_from_upstream"
),
]

View File

@@ -1,3 +0,0 @@
"""Module for v2 views."""
from cms.djangoapps.contentstore.rest_api.v2.views.home import HomePageCoursesViewV2

View File

@@ -0,0 +1,253 @@
"""
API Views for managing & syncing links between upstream & downstream content
API paths (We will move these into proper api_doc_tools annotations soon
https://github.com/openedx/edx-platform/issues/35653):
/api/contentstore/v2/downstreams/{usage_key_string}
GET: Inspect a single downstream block's link to upstream content.
200: Upstream link details successfully fetched. Returns UpstreamLink (may contain an error_message).
404: Downstream block not found or user lacks permission to edit it.
DELETE: Sever a single downstream block's link to upstream content.
204: Block successfully unlinked (or it wasn't linked in the first place). No response body.
404: Downstream block not found or user lacks permission to edit it.
PUT: Establish or modify a single downstream block's link to upstream content. An authoring client could use this
endpoint to add library content in a two-step process, specifically: (1) add a blank block to a course, then
(2) link it to a content library with ?sync=True.
REQUEST BODY: {
"upstream_ref": str, // reference to upstream block (eg, library block usage key)
"sync": bool, // whether to sync in upstream content (False by default)
}
200: Downstream block's upstream link successfully edited (and synced, if requested). Returns UpstreamLink.
400: upstream_ref is malformed, missing, or inaccessible.
400: Content at upstream_ref does not support syncing.
404: Downstream block not found or user lacks permission to edit it.
/api/contentstore/v2/downstreams/{usage_key_string}/sync
POST: Sync a downstream block with upstream content.
200: Downstream block successfully synced with upstream content.
400: Downstream block is not linked to upstream content.
400: Upstream is malformed, missing, or inaccessible.
400: Upstream block does not support syncing.
404: Downstream block not found or user lacks permission to edit it.
DELETE: Decline an available sync for a downstream block.
204: Sync successfuly dismissed. No response body.
400: Downstream block is not linked to upstream content.
404: Downstream block not found or user lacks permission to edit it.
# NOT YET IMPLEMENTED -- Will be needed for full Libraries Relaunch in ~Teak.
/api/contentstore/v2/downstreams
/api/contentstore/v2/downstreams?course_id=course-v1:A+B+C&ready_to_sync=true
GET: List downstream blocks that can be synced, filterable by course or sync-readiness.
200: A paginated list of applicable & accessible downstream blocks. Entries are UpstreamLinks.
UpstreamLink response schema:
{
"upstream_ref": string?
"version_synced": string?,
"version_available": string?,
"version_declined": string?,
"error_message": string?,
"ready_to_sync": Boolean
}
"""
import logging
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from xblock.core import XBlock
from cms.lib.xblock.upstream_sync import (
UpstreamLink, UpstreamLinkException, NoUpstream, BadUpstream, BadDownstream,
fetch_customizable_fields, sync_from_upstream, decline_sync, sever_upstream_link
)
from common.djangoapps.student.auth import has_studio_write_access, has_studio_read_access
from openedx.core.lib.api.view_utils import (
DeveloperErrorViewMixin,
view_auth_classes,
)
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
logger = logging.getLogger(__name__)
class _AuthenticatedRequest(Request):
"""
Alias for the `Request` class which tells mypy to assume that `.user` is not an AnonymousUser.
Using this does NOT ensure the request is actually authenticated--
you will some other way to ensure that, such as `@view_auth_classes(is_authenticated=True)`.
"""
user: User
# TODO: Potential future view.
# @view_auth_classes(is_authenticated=True)
# class DownstreamListView(DeveloperErrorViewMixin, APIView):
# """
# List all blocks which are linked to upstream content, with optional filtering.
# """
# def get(self, request: _AuthenticatedRequest) -> Response:
# """
# Handle the request.
# """
# course_key_string = request.GET['course_id']
# syncable = request.GET['ready_to_sync']
# ...
@view_auth_classes(is_authenticated=True)
class DownstreamView(DeveloperErrorViewMixin, APIView):
"""
Inspect or manage an XBlock's link to upstream content.
"""
def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
"""
Inspect an XBlock's link to upstream content.
"""
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=False)
return Response(UpstreamLink.try_get_for_block(downstream).to_json())
def put(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
"""
Edit an XBlock's link to upstream content.
"""
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
new_upstream_ref = request.data.get("upstream_ref")
# Set `downstream.upstream` so that we can try to sync and/or fetch.
# Note that, if this fails and we raise a 4XX, then we will not call modulstore().update_item,
# thus preserving the former value of `downstream.upstream`.
downstream.upstream = new_upstream_ref
sync_param = request.data.get("sync", "false")
if isinstance(sync_param, str):
sync_param = sync_param.lower()
if sync_param not in ["true", "false", True, False]:
raise ValidationError({"sync": "must be 'true' or 'false'"})
try:
if sync_param == "true" or sync_param is True:
sync_from_upstream(downstream=downstream, user=request.user)
else:
# Even if we're not syncing (i.e., updating the downstream's values with the upstream's), we still need
# to fetch the upstream's customizable values and store them as hidden fields on the downstream. This
# ensures that downstream authors can restore defaults based on the upstream.
fetch_customizable_fields(downstream=downstream, user=request.user)
except BadDownstream as exc:
logger.exception(
"'%s' is an invalid downstream; refusing to set its upstream to '%s'",
usage_key_string,
new_upstream_ref,
)
raise ValidationError(str(exc)) from exc
except BadUpstream as exc:
logger.exception(
"'%s' is an invalid upstream reference; refusing to set it as upstream of '%s'",
new_upstream_ref,
usage_key_string,
)
raise ValidationError({"upstream_ref": str(exc)}) from exc
except NoUpstream as exc:
raise ValidationError({"upstream_ref": "value missing"}) from exc
modulestore().update_item(downstream, request.user.id)
# Note: We call `get_for_block` (rather than `try_get_for_block`) because if anything is wrong with the
# upstream at this point, then that is completely unexpected, so it's appropriate to let the 500 happen.
return Response(UpstreamLink.get_for_block(downstream).to_json())
def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
"""
Sever an XBlock's link to upstream content.
"""
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
try:
sever_upstream_link(downstream)
except NoUpstream as exc:
logger.exception(
"Tried to DELETE upstream link of '%s', but it wasn't linked to anything in the first place. "
"Will do nothing. ",
usage_key_string,
)
else:
modulestore().update_item(downstream, request.user.id)
return Response(status=204)
@view_auth_classes(is_authenticated=True)
class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView):
"""
Accept or decline an opportunity to sync a downstream block from its upstream content.
"""
def post(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
"""
Pull latest updates to the block at {usage_key_string} from its linked upstream content.
"""
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
try:
sync_from_upstream(downstream, request.user)
except UpstreamLinkException as exc:
logger.exception(
"Could not sync from upstream '%s' to downstream '%s'",
downstream.upstream,
usage_key_string,
)
raise ValidationError(detail=str(exc)) from exc
modulestore().update_item(downstream, request.user.id)
# Note: We call `get_for_block` (rather than `try_get_for_block`) because if anything is wrong with the
# upstream at this point, then that is completely unexpected, so it's appropriate to let the 500 happen.
return Response(UpstreamLink.get_for_block(downstream).to_json())
def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
"""
Decline the latest updates to the block at {usage_key_string}.
"""
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
try:
decline_sync(downstream)
except (NoUpstream, BadUpstream, BadDownstream) as exc:
# This is somewhat unexpected. If the upstream link is missing or invalid, then the downstream author
# shouldn't have been prompted to accept/decline a sync in the first place. Of course, they could have just
# hit the HTTP API anyway, or they could be viewing a Studio page which hasn't been refreshed in a while.
# So, it's a 400, not a 500.
logger.exception(
"Tried to decline a sync to downstream '%s', but the upstream link '%s' is invalid.",
usage_key_string,
downstream.upstream,
)
raise ValidationError(str(exc)) from exc
modulestore().update_item(downstream, request.user.id)
return Response(status=204)
def _load_accessible_block(user: User, usage_key_string: str, *, require_write_access: bool) -> XBlock:
"""
Given a logged in-user and a serialized usage key of an upstream-linked XBlock, load it from the ModuleStore,
raising a DRF-friendly exception if anything goes wrong.
Raises NotFound if usage key is malformed, if the user lacks access, or if the block doesn't exist.
"""
not_found = NotFound(detail=f"Block not found or not accessible: {usage_key_string}")
try:
usage_key = UsageKey.from_string(usage_key_string)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed block usage key: {usage_key_string}") from exc
if require_write_access and not has_studio_write_access(user, usage_key.context_key):
raise not_found
if not has_studio_read_access(user, usage_key.context_key):
raise not_found
try:
block = modulestore().get_item(usage_key)
except ItemNotFoundError as exc:
raise not_found from exc
return block

View File

@@ -0,0 +1,276 @@
"""
Unit tests for /api/contentstore/v2/downstreams/* JSON APIs.
"""
from unittest.mock import patch
from django.conf import settings
from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream
from common.djangoapps.student.tests.factories import UserFactory
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from .. import downstreams as downstreams_views
MOCK_LIB_KEY = "lib:OpenedX:CSPROB3"
MOCK_UPSTREAM_REF = "lb:OpenedX:CSPROB3:html:843b4c73-1e2d-4ced-a0ff-24e503cdb3e4"
MOCK_UPSTREAM_LINK = "{mfe_url}/library/{lib_key}/components?usageKey={usage_key}".format(
mfe_url=settings.COURSE_AUTHORING_MICROFRONTEND_URL,
lib_key=MOCK_LIB_KEY,
usage_key=MOCK_UPSTREAM_REF,
)
MOCK_UPSTREAM_ERROR = "your LibraryGPT subscription has expired"
def _get_upstream_link_good_and_syncable(downstream):
return UpstreamLink(
upstream_ref=downstream.upstream,
version_synced=downstream.upstream_version,
version_available=(downstream.upstream_version or 0) + 1,
version_declined=downstream.upstream_version_declined,
error_message=None,
)
def _get_upstream_link_bad(_downstream):
raise BadUpstream(MOCK_UPSTREAM_ERROR)
class _DownstreamViewTestMixin:
"""
Shared data and error test cases.
"""
def setUp(self):
"""
Create a simple course with one unit and two videos, one of which is linked to an "upstream".
"""
super().setUp()
self.course = CourseFactory.create()
chapter = BlockFactory.create(category='chapter', parent=self.course)
sequential = BlockFactory.create(category='sequential', parent=chapter)
unit = BlockFactory.create(category='vertical', parent=sequential)
self.regular_video_key = BlockFactory.create(category='video', parent=unit).usage_key
self.downstream_video_key = BlockFactory.create(
category='video', parent=unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123,
).usage_key
self.fake_video_key = self.course.id.make_usage_key("video", "NoSuchVideo")
self.superuser = UserFactory(username="superuser", password="password", is_staff=True, is_superuser=True)
self.learner = UserFactory(username="learner", password="password")
def call_api(self, usage_key_string):
raise NotImplementedError
def test_404_downstream_not_found(self):
"""
Do we raise 404 if the specified downstream block could not be loaded?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.fake_video_key)
assert response.status_code == 404
assert "not found" in response.data["developer_message"]
def test_404_downstream_not_accessible(self):
"""
Do we raise 404 if the user lacks read access on the specified downstream block?
"""
self.client.login(username="learner", password="password")
response = self.call_api(self.downstream_video_key)
assert response.status_code == 404
assert "not found" in response.data["developer_message"]
class GetDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
"""
Test that `GET /api/v2/contentstore/downstreams/...` inspects a downstream's link to an upstream.
"""
def call_api(self, usage_key_string):
return self.client.get(f"/api/contentstore/v2/downstreams/{usage_key_string}")
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
def test_200_good_upstream(self):
"""
Does the happy path work?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.downstream_video_key)
assert response.status_code == 200
assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF
assert response.data['error_message'] is None
assert response.data['ready_to_sync'] is True
assert response.data['upstream_link'] == MOCK_UPSTREAM_LINK
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_bad)
def test_200_bad_upstream(self):
"""
If the upstream link is broken, do we still return 200, but with an error message in body?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.downstream_video_key)
assert response.status_code == 200
assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF
assert response.data['error_message'] == MOCK_UPSTREAM_ERROR
assert response.data['ready_to_sync'] is False
assert response.data['upstream_link'] is None
def test_200_no_upstream(self):
"""
If the upstream link is missing, do we still return 200, but with an error message in body?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.regular_video_key)
assert response.status_code == 200
assert response.data['upstream_ref'] is None
assert "is not linked" in response.data['error_message']
assert response.data['ready_to_sync'] is False
assert response.data['upstream_link'] is None
class PutDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
"""
Test that `PUT /api/v2/contentstore/downstreams/...` edits a downstream's link to an upstream.
"""
def call_api(self, usage_key_string, sync: str | None = None):
return self.client.put(
f"/api/contentstore/v2/downstreams/{usage_key_string}",
data={
"upstream_ref": MOCK_UPSTREAM_REF,
**({"sync": sync} if sync else {}),
},
content_type="application/json",
)
@patch.object(downstreams_views, "fetch_customizable_fields")
@patch.object(downstreams_views, "sync_from_upstream")
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
def test_200_with_sync(self, mock_sync, mock_fetch):
"""
Does the happy path work (with sync=True)?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.regular_video_key, sync='true')
assert response.status_code == 200
video_after = modulestore().get_item(self.regular_video_key)
assert mock_sync.call_count == 1
assert mock_fetch.call_count == 0
assert video_after.upstream == MOCK_UPSTREAM_REF
@patch.object(downstreams_views, "fetch_customizable_fields")
@patch.object(downstreams_views, "sync_from_upstream")
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
def test_200_no_sync(self, mock_sync, mock_fetch):
"""
Does the happy path work (with sync=False)?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.regular_video_key, sync='false')
assert response.status_code == 200
video_after = modulestore().get_item(self.regular_video_key)
assert mock_sync.call_count == 0
assert mock_fetch.call_count == 1
assert video_after.upstream == MOCK_UPSTREAM_REF
@patch.object(downstreams_views, "fetch_customizable_fields", side_effect=BadUpstream(MOCK_UPSTREAM_ERROR))
def test_400(self, sync: str):
"""
Do we raise a 400 if the provided upstream reference is malformed or not accessible?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.downstream_video_key)
assert response.status_code == 400
assert MOCK_UPSTREAM_ERROR in response.data['developer_message']['upstream_ref']
video_after = modulestore().get_item(self.regular_video_key)
assert video_after.upstream is None
class DeleteDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
"""
Test that `DELETE /api/v2/contentstore/downstreams/...` severs a downstream's link to an upstream.
"""
def call_api(self, usage_key_string):
return self.client.delete(f"/api/contentstore/v2/downstreams/{usage_key_string}")
@patch.object(downstreams_views, "sever_upstream_link")
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
def test_204(self, mock_sever):
"""
Does the happy path work?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.downstream_video_key)
assert response.status_code == 204
assert mock_sever.call_count == 1
@patch.object(downstreams_views, "sever_upstream_link")
def test_204_no_upstream(self, mock_sever):
"""
If there's no upsream, do we still happily return 204?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.regular_video_key)
assert response.status_code == 204
assert mock_sever.call_count == 1
class _DownstreamSyncViewTestMixin(_DownstreamViewTestMixin):
"""
Shared tests between the /api/contentstore/v2/downstreams/.../sync endpoints.
"""
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_bad)
def test_400_bad_upstream(self):
"""
If the upstream link is bad, do we get a 400?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.downstream_video_key)
assert response.status_code == 400
assert MOCK_UPSTREAM_ERROR in response.data["developer_message"][0]
def test_400_no_upstream(self):
"""
If the upstream link is missing, do we get a 400?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.regular_video_key)
assert response.status_code == 400
assert "is not linked" in response.data["developer_message"][0]
class PostDownstreamSyncViewTest(_DownstreamSyncViewTestMixin, SharedModuleStoreTestCase):
"""
Test that `POST /api/v2/contentstore/downstreams/.../sync` initiates a sync from the linked upstream.
"""
def call_api(self, usage_key_string):
return self.client.post(f"/api/contentstore/v2/downstreams/{usage_key_string}/sync")
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
@patch.object(downstreams_views, "sync_from_upstream")
def test_200(self, mock_sync_from_upstream):
"""
Does the happy path work?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.downstream_video_key)
assert response.status_code == 200
assert mock_sync_from_upstream.call_count == 1
class DeleteDownstreamSyncViewtest(_DownstreamSyncViewTestMixin, SharedModuleStoreTestCase):
"""
Test that `DELETE /api/v2/contentstore/downstreams/.../sync` declines a sync from the linked upstream.
"""
def call_api(self, usage_key_string):
return self.client.delete(f"/api/contentstore/v2/downstreams/{usage_key_string}/sync")
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
@patch.object(downstreams_views, "decline_sync")
def test_204(self, mock_decline_sync):
"""
Does the happy path work?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.downstream_video_key)
assert response.status_code == 204
assert mock_decline_sync.call_count == 1

View File

@@ -45,6 +45,7 @@ class HomePageCoursesViewV2Test(CourseTestCase):
org=archived_course_key.org,
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
)
self.non_staff_client, _ = self.create_non_staff_authed_user_client()
def test_home_page_response(self):
"""Get list of courses available to the logged in user.
@@ -62,7 +63,7 @@ class HomePageCoursesViewV2Test(CourseTestCase):
OrderedDict([
("course_key", course_id),
("display_name", self.course.display_name),
("lms_link", f'//{settings.LMS_BASE}/courses/{course_id}/jump_to/{self.course.location}'),
("lms_link", f'{settings.LMS_ROOT_URL}/courses/{course_id}/jump_to/{self.course.location}'),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.course.id)}'),
("number", self.course.number),
("org", self.course.org),
@@ -76,7 +77,7 @@ class HomePageCoursesViewV2Test(CourseTestCase):
("display_name", self.archived_course.display_name),
(
"lms_link",
f'//{settings.LMS_BASE}/courses/{archived_course_id}/jump_to/{self.archived_course.location}'
f'{settings.LMS_ROOT_URL}/courses/{archived_course_id}/jump_to/{self.archived_course.location}'
),
(
"cms_link",
@@ -139,7 +140,7 @@ class HomePageCoursesViewV2Test(CourseTestCase):
self.assertEqual(response.data["results"]["courses"], [OrderedDict([
("course_key", str(self.course.id)),
("display_name", self.course.display_name),
("lms_link", f'//{settings.LMS_BASE}/courses/{str(self.course.id)}/jump_to/{self.course.location}'),
("lms_link", f'{settings.LMS_ROOT_URL}/courses/{str(self.course.id)}/jump_to/{self.course.location}'),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.course.id)}'),
("number", self.course.number),
("org", self.course.org),
@@ -164,7 +165,11 @@ class HomePageCoursesViewV2Test(CourseTestCase):
("display_name", self.archived_course.display_name),
(
"lms_link",
f'//{settings.LMS_BASE}/courses/{str(self.archived_course.id)}/jump_to/{self.archived_course.location}',
'{url_root}/courses/{course_id}/jump_to/{location}'.format(
url_root=settings.LMS_ROOT_URL,
course_id=str(self.archived_course.id),
location=self.archived_course.location
),
),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}'),
("number", self.archived_course.number),
@@ -190,7 +195,11 @@ class HomePageCoursesViewV2Test(CourseTestCase):
("display_name", self.archived_course.display_name),
(
"lms_link",
f'//{settings.LMS_BASE}/courses/{str(self.archived_course.id)}/jump_to/{self.archived_course.location}',
'{url_root}/courses/{course_id}/jump_to/{location}'.format(
url_root=settings.LMS_ROOT_URL,
course_id=str(self.archived_course.id),
location=self.archived_course.location
),
),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}'),
("number", self.archived_course.number),
@@ -239,3 +248,100 @@ class HomePageCoursesViewV2Test(CourseTestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
mock_modulestore().get_course_summaries.assert_called_once()
mock_course_overview.get_all_courses.assert_not_called()
@ddt.data(
("active_only", "true"),
("archived_only", "true"),
("search", "sample"),
("order", "org"),
("page", 1),
)
@ddt.unpack
def test_if_empty_list_of_courses(self, query_param, value):
"""Get list of courses when no courses are available.
Expected result:
- An empty list of courses available to the logged in user.
"""
self.active_course.delete()
self.archived_course.delete()
response = self.client.get(self.api_v2_url, {query_param: value})
self.assertEqual(len(response.data['results']['courses']), 0)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
@ddt.data(
("active_only", "true", 2, 0),
("archived_only", "true", 0, 1),
("search", "foo", 1, 0),
("search", "demo", 0, 1),
("order", "org", 2, 1),
("order", "display_name", 2, 1),
("order", "number", 2, 1),
("order", "run", 2, 1)
)
@ddt.unpack
def test_filter_and_ordering_courses(
self,
filter_key,
filter_value,
expected_active_length,
expected_archived_length
):
"""Get list of courses when filter and ordering are applied.
This test creates two courses besides the default courses created in the setUp method.
Then filters and orders them based on the filter_key and filter_value passed as query parameters.
Expected result:
- A list of courses available to the logged in user for the specified filter and order.
"""
archived_course_key = self.store.make_course_key("demo-org", "demo-number", "demo-run")
CourseOverviewFactory.create(
display_name="Course (Demo)",
id=archived_course_key,
org=archived_course_key.org,
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
)
active_course_key = self.store.make_course_key("foo-org", "foo-number", "foo-run")
CourseOverviewFactory.create(
display_name="Course (Foo)",
id=active_course_key,
org=active_course_key.org,
)
response = self.client.get(self.api_v2_url, {filter_key: filter_value})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
len([course for course in response.data["results"]["courses"] if course["is_active"]]),
expected_active_length
)
self.assertEqual(
len([course for course in response.data["results"]["courses"] if not course["is_active"]]),
expected_archived_length
)
@ddt.data(
("active_only", "true"),
("archived_only", "true"),
("search", "sample"),
("order", "org"),
("page", 1),
)
@ddt.unpack
def test_if_empty_list_of_courses_non_staff(self, query_param, value):
"""Get list of courses when no courses are available for non-staff users.
Expected result:
- An empty list of courses available to the logged in user.
"""
self.active_course.delete()
self.archived_course.delete()
response = self.non_staff_client.get(self.api_v2_url, {query_param: value})
self.assertEqual(len(response.data["results"]["courses"]), 0)
self.assertEqual(response.status_code, status.HTTP_200_OK)

View File

@@ -504,6 +504,11 @@ class TestCoursewareSearchIndexer(MixedWithOptionsTestCase):
""" Test for removing course from CourseAboutSearchIndexer """
self._test_delete_course_from_search_index_after_course_deletion(self.store)
def test_empty_course(self):
empty_course = CourseFactory.create(modulestore=self.store, start=datetime(2015, 3, 1, tzinfo=UTC))
added_to_index = CoursewareSearchIndexer.do_course_reindex(self.store, empty_course.id)
assert added_to_index == 0
@patch('django.conf.settings.SEARCH_ENGINE', 'search.tests.utils.ForceRefreshElasticSearchEngine')
@ddt.ddt

View File

@@ -0,0 +1,98 @@
"""
Unit tests for the asset upload endpoint.
"""
from datetime import datetime
from urllib.parse import urljoin
from pytz import UTC
from django.test import override_settings
from cms.djangoapps.contentstore import asset_storage_handlers
from opaque_keys.edx.locator import CourseLocator
from openedx_filters import PipelineStep
from xmodule.contentstore.content import StaticContent
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
class TestPageURLRequestedPipelineStep(PipelineStep):
"""
Utility class used when getting steps for pipeline.
"""
def run_filter(self, url, org): # pylint: disable=arguments-differ
"""Pipeline step that modifies lms url creation."""
url = "https://lms-url-creation"
org = "org"
return {
"url": url,
"org": org,
}
class LMSPageURLRequestedFiltersTest(ModuleStoreTestCase):
"""
Tests for the Open edX Filters associated with the lms url requested process.
This class guarantees that the following filters are triggered during the microsite render:
- LMSPageURLRequested
"""
def setUp(self): # pylint: disable=arguments-differ
super().setUp()
self.upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
self.content_type = 'image/jpg'
self.course_key = CourseLocator('org', 'class', 'run')
self.location = self.course_key.make_asset_key('asset', 'my_file_name.jpg')
self.thumbnail_location = self.course_key.make_asset_key('thumbnail', 'my_file_name_thumb.jpg')
self.asset_url = StaticContent.serialize_asset_key_with_slash(self.location)
@override_settings(
OPEN_EDX_FILTERS_CONFIG={
"org.openedx.course_authoring.lms.page.url.requested.v1": {
"pipeline": [
"common.djangoapps.util.tests.test_filters.TestPageURLRequestedPipelineStep",
],
"fail_silently": False,
},
},
)
def test_lms_url_requested_filter_executed(self):
"""
Test that filter get new LMS URL for asset URL generation
based on the course organization settings for org.
Expected result:
- LMSPageURLRequested is triggered and executes TestPageURLRequestedPipelineStep.
- The arguments that the receiver gets are the arguments used by the filter.
"""
output = asset_storage_handlers.get_asset_json(
"my_file",
self.content_type,
self.upload_date,
self.location,
self.thumbnail_location,
True,
self.course_key
)
self.assertEqual(output.get('external_url'), urljoin('https://lms-url-creation', self.asset_url))
@override_settings(OPEN_EDX_FILTERS_CONFIG={}, LMS_ROOT_URL="https://lms-base")
def test_lms_url_requested_without_filter_configuration(self):
"""
Test that filter get new LMS URL for asset URL generation
based on LMS_ROOT_URL settings because OPEN_EDX_FILTERS_CONFIG is not set.
Expected result:
- Returns the asset URL with domain base LMS_ROOT_URL.
- The get process ends successfully.
"""
output = asset_storage_handlers.get_asset_json(
"my_file",
self.content_type,
self.upload_date,
self.location,
self.thumbnail_location,
True,
self.course_key
)
self.assertEqual(output.get('external_url'), urljoin('https://lms-base', self.asset_url))

View File

@@ -960,4 +960,32 @@ class CourseUpdateNotificationTests(ModuleStoreTestCase):
send_course_update_notification(self.course.id, content, self.user)
assert Notification.objects.all().count() == 1
notification = Notification.objects.first()
assert notification.content == "<p><strong><p>content</p></strong></p>"
assert notification.content == "<p><strong>content</strong></p>"
def test_if_content_is_plain_text(self):
"""
Test that the course_update notification is sent.
"""
user = UserFactory()
CourseEnrollment.enroll(user=user, course_key=self.course.id)
assert Notification.objects.all().count() == 0
content = "<p>content<p>Sub content</p><h1>heading</h1></p><img src='' />"
send_course_update_notification(self.course.id, content, self.user)
assert Notification.objects.all().count() == 1
notification = Notification.objects.first()
assert notification.content == "<p><strong>content Sub content heading</strong></p>"
def test_if_html_unescapes(self):
"""
Tests if html unescapes when creating content of course update notification
"""
user = UserFactory()
CourseEnrollment.enroll(user=user, course_key=self.course.id)
assert Notification.objects.all().count() == 0
content = "<p>&lt;p&gt; &amp;nbsp;&lt;/p&gt;<br />"\
"&lt;p&gt;abcd&lt;/p&gt;<br />"\
"&lt;p&gt;&amp;nbsp;&lt;/p&gt;<br /></p>"
send_course_update_notification(self.course.id, content, self.user)
assert Notification.objects.all().count() == 1
notification = Notification.objects.first()
assert notification.content == "<p><strong>abcd</strong></p>"

View File

@@ -2,6 +2,7 @@
CMS feature toggles.
"""
from edx_toggles.toggles import SettingDictToggle, WaffleFlag
from openedx.core.djangoapps.content.search import api as search_api
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
# .. toggle_name: FEATURES['ENABLE_EXPORT_GIT']
@@ -593,3 +594,96 @@ def default_enable_flexible_peer_openassessments(course_key):
level to opt in/out of rolling forward this feature.
"""
return DEFAULT_ENABLE_FLEXIBLE_PEER_OPENASSESSMENTS.is_enabled(course_key)
# .. toggle_name: FEATURES['ENABLE_CONTENT_LIBRARIES']
# .. toggle_implementation: SettingDictToggle
# .. toggle_default: True
# .. toggle_description: Enables use of the legacy and v2 libraries waffle flags.
# Note that legacy content libraries are only supported in courses using split mongo.
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2015-03-06
# .. toggle_target_removal_date: 2025-04-09
# .. toggle_warning: This flag is deprecated in Sumac, and will be removed in favor of the disable_legacy_libraries and
# disable_new_libraries waffle flags.
ENABLE_CONTENT_LIBRARIES = SettingDictToggle(
"FEATURES", "ENABLE_CONTENT_LIBRARIES", default=True, module_name=__name__
)
# .. toggle_name: contentstore.new_studio_mfe.disable_legacy_libraries
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: Hides legacy (v1) Libraries tab in Authoring MFE.
# This toggle interacts with ENABLE_CONTENT_LIBRARIES toggle: if this is disabled, then legacy libraries are also
# disabled.
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2024-10-02
# .. toggle_target_removal_date: 2025-04-09
# .. toggle_tickets: https://github.com/openedx/frontend-app-authoring/issues/1334
# .. toggle_warning: Legacy libraries are deprecated in Sumac, cf https://github.com/openedx/edx-platform/issues/32457
DISABLE_LEGACY_LIBRARIES = WaffleFlag(
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.disable_legacy_libraries',
__name__,
CONTENTSTORE_LOG_PREFIX,
)
def libraries_v1_enabled():
"""
Returns a boolean if Libraries V2 is enabled in the new Studio Home.
"""
return (
ENABLE_CONTENT_LIBRARIES.is_enabled() and
not DISABLE_LEGACY_LIBRARIES.is_enabled()
)
# .. toggle_name: contentstore.new_studio_mfe.disable_new_libraries
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: Hides new Libraries v2 tab in Authoring MFE.
# This toggle interacts with settings.MEILISEARCH_ENABLED and ENABLE_CONTENT_LIBRARIES toggle: if these flags are
# False, then v2 libraries are also disabled.
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2024-10-02
# .. toggle_target_removal_date: 2025-04-09
# .. toggle_tickets: https://github.com/openedx/frontend-app-authoring/issues/1334
# .. toggle_warning: Libraries v2 are in beta for Sumac, will be fully supported in Teak.
DISABLE_NEW_LIBRARIES = WaffleFlag(
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.disable_new_libraries',
__name__,
CONTENTSTORE_LOG_PREFIX,
)
def libraries_v2_enabled():
"""
Returns a boolean if Libraries V2 is enabled in the new Studio Home.
Requires the ENABLE_CONTENT_LIBRARIES feature flag to be enabled, plus Meilisearch.
"""
return (
ENABLE_CONTENT_LIBRARIES.is_enabled() and
search_api.is_meilisearch_enabled() and
not DISABLE_NEW_LIBRARIES.is_enabled()
)
# .. toggle_name: contentstore.enable_course_optimizer
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: This flag enables the use of unique anonymous_user_id during studio preview
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2022-05-04
# .. toggle_target_removal_date: 2022-05-30
# .. toggle_tickets: MST-1455
ENABLE_COURSE_OPTIMIZER = CourseWaffleFlag(
f'{CONTENTSTORE_NAMESPACE}.enable_course_optimizer', __name__
)
def enable_course_optimizer(course_id):
"""
Returns a boolean if individualized anonymous_user_id is enabled on the course
"""
return ENABLE_COURSE_OPTIMIZER.is_enabled(course_id)

View File

@@ -3,12 +3,13 @@ Common utility functions useful throughout the contentstore
"""
from __future__ import annotations
import configparser
import html
import logging
import re
from collections import defaultdict
from contextlib import contextmanager
from datetime import datetime, timezone
from urllib.parse import quote_plus
from urllib.parse import quote_plus, urlencode, urlunparse, urlparse
from uuid import uuid4
from bs4 import BeautifulSoup
@@ -34,7 +35,32 @@ from milestones import api as milestones_api
from pytz import UTC
from xblock.fields import Scope
from cms.djangoapps.contentstore.toggles import exam_setting_view_enabled
from cms.djangoapps.contentstore.toggles import (
exam_setting_view_enabled,
libraries_v1_enabled,
libraries_v2_enabled,
split_library_view_on_dashboard,
use_new_advanced_settings_page,
use_new_course_outline_page,
use_new_certificates_page,
use_new_export_page,
use_new_files_uploads_page,
use_new_grading_page,
use_new_group_configurations_page,
use_new_course_team_page,
use_new_home_page,
use_new_import_page,
use_new_schedule_details_page,
use_new_text_editor,
use_new_textbooks_page,
use_new_unit_page,
use_new_updates_page,
use_new_video_editor,
use_new_video_uploads_page,
use_new_custom_pages,
)
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from common.djangoapps.course_action_state.models import CourseRerunUIStateManager, CourseRerunState
from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError
from common.djangoapps.course_modes.models import CourseMode
@@ -75,29 +101,6 @@ from openedx.core.lib.html_to_text import html_to_text
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
from cms.djangoapps.contentstore.toggles import (
split_library_view_on_dashboard,
use_new_advanced_settings_page,
use_new_course_outline_page,
use_new_certificates_page,
use_new_export_page,
use_new_files_uploads_page,
use_new_grading_page,
use_new_group_configurations_page,
use_new_course_team_page,
use_new_home_page,
use_new_import_page,
use_new_schedule_details_page,
use_new_text_editor,
use_new_textbooks_page,
use_new_unit_page,
use_new_updates_page,
use_new_video_editor,
use_new_video_uploads_page,
use_new_custom_pages,
)
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.data import CertificatesDisplayBehaviors
@@ -190,31 +193,30 @@ def get_lms_link_for_item(location, preview=False):
"""
assert isinstance(location, UsageKey)
# checks LMS_BASE value in site configuration for the given course_org_filter(org)
# if not found returns settings.LMS_BASE
# checks LMS_ROOT_URL value in site configuration for the given course_org_filter(org)
# if not found returns settings.LMS_ROOT_URL
lms_base = SiteConfiguration.get_value_for_org(
location.org,
"LMS_BASE",
settings.LMS_BASE
"LMS_ROOT_URL",
settings.LMS_ROOT_URL
)
query_string = ''
if lms_base is None:
return None
if preview:
# checks PREVIEW_LMS_BASE value in site configuration for the given course_org_filter(org)
# if not found returns settings.FEATURES.get('PREVIEW_LMS_BASE')
lms_base = SiteConfiguration.get_value_for_org(
location.org,
"PREVIEW_LMS_BASE",
settings.FEATURES.get('PREVIEW_LMS_BASE')
)
params = {'preview': '1'}
query_string = urlencode(params)
return "//{lms_base}/courses/{course_key}/jump_to/{location}".format(
lms_base=lms_base,
url_parts = list(urlparse(lms_base))
url_parts[2] = '/courses/{course_key}/jump_to/{location}'.format(
course_key=str(location.course_key),
location=str(location),
)
url_parts[4] = query_string
return urlunparse(url_parts)
def get_lms_link_for_certificate_web_view(course_key, mode):
@@ -427,6 +429,18 @@ def get_course_outline_url(course_locator) -> str:
return course_outline_url
def get_library_content_picker_url(course_locator) -> str:
"""
Gets course authoring microfrontend library content picker URL for the given parent block.
"""
content_picker_url = None
if libraries_v2_enabled():
mfe_base_url = get_course_authoring_url(course_locator)
content_picker_url = f'{mfe_base_url}/component-picker?variant=published'
return content_picker_url
def get_unit_url(course_locator, unit_locator) -> str:
"""
Gets course authoring microfrontend URL for unit page view.
@@ -1536,11 +1550,13 @@ def get_library_context(request, request_is_json=False):
_format_library_for_view,
)
from cms.djangoapps.contentstore.views.library import (
LIBRARIES_ENABLED,
user_can_view_create_library_button,
)
from openedx.core.djangoapps.content_libraries.api import (
user_can_create_library,
)
libraries = _accessible_libraries_iter(request.user) if LIBRARIES_ENABLED else []
libraries = _accessible_libraries_iter(request.user) if libraries_v1_enabled() else []
data = {
'libraries': [_format_library_for_view(lib, request) for lib in libraries],
}
@@ -1550,8 +1566,9 @@ def get_library_context(request, request_is_json=False):
**data,
'in_process_course_actions': [],
'courses': [],
'libraries_enabled': LIBRARIES_ENABLED,
'libraries_enabled': libraries_v1_enabled(),
'show_new_library_button': user_can_view_create_library_button(request.user) and request.user.is_active,
'show_new_library_v2_button': user_can_create_library(request.user),
'user': request.user,
'request_course_creator_url': reverse('request_course_creator'),
'course_creator_status': _get_course_creator_status(request.user),
@@ -1671,9 +1688,11 @@ def get_home_context(request, no_course=False):
ENABLE_GLOBAL_STAFF_OPTIMIZATION,
)
from cms.djangoapps.contentstore.views.library import (
LIBRARIES_ENABLED,
user_can_view_create_library_button,
)
from openedx.core.djangoapps.content_libraries.api import (
user_can_create_library,
)
active_courses = []
archived_courses = []
@@ -1687,7 +1706,7 @@ def get_home_context(request, no_course=False):
if not no_course:
active_courses, archived_courses, in_process_course_actions = get_course_context(request)
if not split_library_view_on_dashboard() and LIBRARIES_ENABLED and not no_course:
if not split_library_view_on_dashboard() and libraries_v1_enabled() and not no_course:
libraries = get_library_context(request, True)['libraries']
home_context = {
@@ -1695,11 +1714,14 @@ def get_home_context(request, no_course=False):
'split_studio_home': split_library_view_on_dashboard(),
'archived_courses': archived_courses,
'in_process_course_actions': in_process_course_actions,
'libraries_enabled': LIBRARIES_ENABLED,
'libraries_enabled': libraries_v1_enabled(),
'libraries_v1_enabled': libraries_v1_enabled(),
'libraries_v2_enabled': libraries_v2_enabled(),
'taxonomies_enabled': not is_tagging_feature_disabled(),
'taxonomy_list_mfe_url': get_taxonomy_list_url(),
'libraries': libraries,
'show_new_library_button': user_can_view_create_library_button(user),
'show_new_library_v2_button': user_can_create_library(user),
'user': user,
'request_course_creator_url': reverse('request_course_creator'),
'course_creator_status': _get_course_creator_status(user),
@@ -2041,6 +2063,7 @@ def get_container_handler_context(request, usage_key, course, xblock): # pylint
'user_clipboard': user_clipboard,
'is_fullwidth_content': is_library_xblock,
'course_sequence_ids': course_sequence_ids,
'library_content_picker_url': get_library_content_picker_url(course.id),
}
return context
@@ -2243,23 +2266,11 @@ def clean_html_body(html_body):
"""
Get html body, remove tags and limit to 500 characters
"""
html_body = html.unescape(html_body).strip()
html_body = BeautifulSoup(Truncator(html_body).chars(500, html=True), 'html.parser')
tags_to_remove = [
"a", "link", # Link Tags
"img", "picture", "source", # Image Tags
"video", "track", # Video Tags
"audio", # Audio Tags
"embed", "object", "iframe", # Embedded Content
"script"
]
# Remove the specified tags while keeping their content
for tag in tags_to_remove:
for match in html_body.find_all(tag):
match.unwrap()
return str(html_body)
text_content = html_body.get_text(separator=" ").strip()
text_content = text_content.replace('\n', '').replace('\r', '')
return text_content
def send_course_update_notification(course_key, content, user):

View File

@@ -967,26 +967,38 @@ def get_course_youtube_edx_video_ids(course_id):
"""
Get a list of youtube edx_video_ids
"""
error_msg = "Invalid course_key: '%s'." % course_id
try:
invalid_key_error_msg = "Invalid course_key: '%s'." % course_id
unexpected_error_msg = "Unexpected error occurred for course_id: '%s'." % course_id
try: # lint-amnesty, pylint: disable=too-many-nested-blocks
course_key = CourseKey.from_string(course_id)
course = modulestore().get_course(course_key)
except InvalidKeyError:
return JsonResponse({'error': error_msg}, status=500)
blocks = []
block_yt_field = 'youtube_id_1_0'
block_edx_id_field = 'edx_video_id'
if hasattr(course, 'get_children'):
for section in course.get_children():
for subsection in section.get_children():
for vertical in subsection.get_children():
for block in vertical.get_children():
blocks.append(block)
edx_video_ids = []
for block in blocks:
if hasattr(block, block_yt_field) and getattr(block, block_yt_field):
if getattr(block, block_edx_id_field):
edx_video_ids.append(getattr(block, block_edx_id_field))
blocks = []
block_yt_field = 'youtube_id_1_0'
block_edx_id_field = 'edx_video_id'
if hasattr(course, 'get_children'):
for section in course.get_children():
for subsection in section.get_children():
for vertical in subsection.get_children():
for block in vertical.get_children():
blocks.append(block)
edx_video_ids = []
for block in blocks:
if hasattr(block, block_yt_field) and getattr(block, block_yt_field):
if getattr(block, block_edx_id_field):
edx_video_ids.append(getattr(block, block_edx_id_field))
except InvalidKeyError as error:
LOGGER.exception(
f"InvalidKeyError occurred while getting YouTube video IDs for course_id: {course_id}: {error}"
)
return JsonResponse({'error': invalid_key_error_msg}, status=500)
except Exception as error:
LOGGER.exception(
f"Unexpected error occurred while getting YouTube video IDs for course_id: {course_id}: {error}"
)
return JsonResponse({'error': unexpected_error_msg}, status=500)
return JsonResponse({'edx_video_ids': edx_video_ids}, status=200)

View File

@@ -135,6 +135,8 @@ def xblock_handler(request, usage_key_string=None):
if duplicate_source_locator is not present
:staged_content: use "clipboard" to paste from the OLX user's clipboard. (Incompatible with all other
fields except parent_locator)
:library_content_key: the key of the library content to add. (Incompatible with
all other fields except parent_locator)
The locator (unicode representation of a UsageKey) for the created xblock (minus children) is returned.
"""
return handle_xblock(request, usage_key_string)

View File

@@ -11,6 +11,7 @@ from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponseBadRequest
from django.shortcuts import redirect
from django.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.http import require_GET
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
@@ -25,7 +26,12 @@ from common.djangoapps.student.auth import has_course_author_access
from common.djangoapps.xblock_django.api import authorable_xblocks, disabled_xblocks
from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag
from cms.djangoapps.contentstore.helpers import is_unit
from cms.djangoapps.contentstore.toggles import use_new_problem_editor, use_new_unit_page
from cms.djangoapps.contentstore.toggles import (
libraries_v1_enabled,
libraries_v2_enabled,
use_new_problem_editor,
use_new_unit_page,
)
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import load_services_for_studio
from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
@@ -35,13 +41,26 @@ from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, py
__all__ = [
'container_handler',
'component_handler'
'component_handler',
'container_embed_handler',
]
log = logging.getLogger(__name__)
# NOTE: This list is disjoint from ADVANCED_COMPONENT_TYPES
COMPONENT_TYPES = ['discussion', 'library', 'html', 'openassessment', 'problem', 'video', 'drag-and-drop-v2']
COMPONENT_TYPES = [
'html',
'video',
'problem',
'itembank',
'library_v2', # Not an XBlock
'library',
'discussion',
'openassessment',
'drag-and-drop-v2',
]
BETA_COMPONENT_TYPES = ['library_v2', 'itembank']
ADVANCED_COMPONENT_TYPES = sorted({name for name, class_ in XBlock.load_classes()} - set(COMPONENT_TYPES))
@@ -56,7 +75,7 @@ CONTAINER_TEMPLATES = [
"add-xblock-component-support-legend", "add-xblock-component-support-level", "add-xblock-component-menu-problem",
"xblock-string-field-editor", "xblock-access-editor", "publish-xblock", "publish-history", "tag-list",
"unit-outline", "container-message", "container-access", "license-selector", "copy-clipboard-button",
"edit-title-button",
"edit-title-button", "edit-upstream-alert",
]
@@ -95,6 +114,10 @@ def _load_mixed_class(category):
"""
Load an XBlock by category name, and apply all defined mixins
"""
# Libraries v2 content doesn't have an XBlock.
if category == 'library_v2':
return None
component_class = XBlock.load_class(category)
mixologist = Mixologist(settings.XBLOCK_MIXINS)
return mixologist.mix(component_class)
@@ -141,6 +164,36 @@ def container_handler(request, usage_key_string): # pylint: disable=too-many-st
return HttpResponseBadRequest("Only supports HTML requests")
@require_GET
@login_required
@xframe_options_exempt
def container_embed_handler(request, usage_key_string): # pylint: disable=too-many-statements
"""
Returns an HttpResponse with HTML content for the container XBlock.
The returned HTML is a chromeless rendering of the XBlock.
GET
html: returns the HTML page for editing a container
json: not currently supported
"""
# Avoiding a circular dependency
from ..utils import get_container_handler_context
try:
usage_key = UsageKey.from_string(usage_key_string)
except InvalidKeyError: # Raise Http404 on invalid 'usage_key_string'
return HttpResponseBadRequest()
with modulestore().bulk_operations(usage_key.course_key):
try:
course, xblock, lms_link, preview_lms_link = _get_item_in_course(request, usage_key)
except ItemNotFoundError:
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
container_handler_context = get_container_handler_context(request, usage_key, course, xblock)
return render_to_response('container_chromeless.html', container_handler_context)
def get_component_templates(courselike, library=False): # lint-amnesty, pylint: disable=too-many-statements
"""
Returns the applicable component templates that can be used by the specified course or library.
@@ -215,7 +268,9 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
'problem': _("Problem"),
'video': _("Video"),
'openassessment': _("Open Response"),
'library': _("Library Content"),
'library': _("Legacy Library"),
'library_v2': _("Library Content"),
'itembank': _("Problem Bank"),
'drag-and-drop-v2': _("Drag and Drop"),
}
@@ -226,7 +281,14 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
component_types = COMPONENT_TYPES[:]
# Libraries do not support discussions, drag-and-drop, and openassessment and other libraries
component_not_supported_by_library = ['discussion', 'library', 'openassessment', 'drag-and-drop-v2']
component_not_supported_by_library = [
'discussion',
'library',
'openassessment',
'drag-and-drop-v2',
'library_v2',
'itembank',
]
if library:
component_types = [component for component in component_types
if component not in set(component_not_supported_by_library)]
@@ -245,7 +307,7 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
templates_for_category = []
component_class = _load_mixed_class(category)
if support_level_without_template and category != 'library':
if support_level_without_template and category not in ['library']:
# add the default template with localized display name
# TODO: Once mixins are defined per-application, rather than per-runtime,
# this should use a cms mixed-in class. (cpennington)
@@ -366,7 +428,8 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
"type": category,
"templates": templates_for_category,
"display_name": component_display_names[category],
"support_legend": create_support_legend_dict()
"support_legend": create_support_legend_dict(),
"beta": category in BETA_COMPONENT_TYPES,
})
# Libraries do not support advanced components at this time.
@@ -416,7 +479,7 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
course_advanced_keys
)
if advanced_component_templates['templates']:
component_templates.insert(0, advanced_component_templates)
component_templates.append(advanced_component_templates)
return component_templates
@@ -440,6 +503,11 @@ def _filter_disabled_blocks(all_blocks):
Filter out disabled xblocks from the provided list of xblock names.
"""
disabled_block_names = [block.name for block in disabled_xblocks()]
if not libraries_v1_enabled():
disabled_block_names.append('library')
if not libraries_v2_enabled():
disabled_block_names.append('library_v2')
disabled_block_names.append('itembank')
return [block_name for block_name in all_blocks if block_name not in disabled_block_names]

View File

@@ -15,6 +15,7 @@ from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.core.exceptions import FieldError, PermissionDenied, ValidationError as DjangoValidationError
from django.db.models import QuerySet
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect
from django.urls import reverse
@@ -575,6 +576,10 @@ def _accessible_courses_list_from_groups(request):
if course_keys:
courses_list = CourseOverview.get_all_courses(filter_={'id__in': course_keys})
else:
# If no course keys are found for the current user, then return without filtering
# or ordering the courses list.
return courses_list, []
search_query, order, active_only, archived_only = get_query_params_if_present(request)
courses_list = get_filtered_and_ordered_courses(
@@ -588,7 +593,11 @@ def _accessible_courses_list_from_groups(request):
return courses_list, []
def get_courses_by_status(active_only, archived_only, course_overviews):
def get_courses_by_status(
active_only: bool,
archived_only: bool,
course_overviews: QuerySet[CourseOverview]
) -> QuerySet[CourseOverview]:
"""
Return course overviews based on a base queryset filtered by a status.
@@ -602,7 +611,10 @@ def get_courses_by_status(active_only, archived_only, course_overviews):
return CourseOverview.get_courses_by_status(active_only, archived_only, course_overviews)
def get_courses_by_search_query(search_query, course_overviews):
def get_courses_by_search_query(
search_query: str | None,
course_overviews: QuerySet[CourseOverview]
) -> QuerySet[CourseOverview]:
"""Return course overviews based on a base queryset filtered by a search query.
Args:
@@ -614,7 +626,10 @@ def get_courses_by_search_query(search_query, course_overviews):
return CourseOverview.get_courses_matching_query(search_query, course_overviews=course_overviews)
def get_courses_order_by(order_query, course_overviews):
def get_courses_order_by(
order_query: str | None,
course_overviews: QuerySet[CourseOverview]
) -> QuerySet[CourseOverview]:
"""Return course overviews based on a base queryset ordered by a query.
Args:

View File

@@ -42,6 +42,7 @@ from common.djangoapps.student.roles import (
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
from ..utils import add_instructor, reverse_library_url
from ..toggles import libraries_v1_enabled
from .component import CONTAINER_TEMPLATES, get_component_templates
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info
from .user import user_with_role
@@ -50,13 +51,11 @@ __all__ = ['library_handler', 'manage_library_users']
log = logging.getLogger(__name__)
LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False)
def _user_can_create_library_for_org(user, org=None):
"""
Helper method for returning the library creation status for a particular user,
taking into account the value LIBRARIES_ENABLED.
taking into account the libraries_v1_enabled toggle.
if the ENABLE_CREATOR_GROUP value is False, then any user can create a library (in any org),
if library creation is enabled.
@@ -69,7 +68,7 @@ def _user_can_create_library_for_org(user, org=None):
Course Staff: Can make libraries in the organization which has courses of which they are staff.
Course Admin: Can make libraries in the organization which has courses of which they are Admin.
"""
if not LIBRARIES_ENABLED:
if not libraries_v1_enabled():
return False
elif user.is_staff:
return True
@@ -125,7 +124,7 @@ def library_handler(request, library_key_string=None):
"""
RESTful interface to most content library related functionality.
"""
if not LIBRARIES_ENABLED:
if not libraries_v1_enabled():
log.exception("Attempted to use the content library API when the libraries feature is disabled.")
raise Http404 # Should never happen because we test the feature in urls.py also

View File

@@ -300,8 +300,9 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
selected_groups_label = _('Access restricted to: {list_of_groups}').format(list_of_groups=selected_groups_label) # lint-amnesty, pylint: disable=line-too-long
course = modulestore().get_course(xblock.location.course_key)
can_edit = context.get('can_edit', True)
can_add = context.get('can_add', True)
# Is this a course or a library?
is_course = xblock.scope_ids.usage_id.context_key.is_course
is_course = xblock.context_key.is_course
tags_count_map = context.get('tags_count_map')
tags_count = 0
if tags_count_map:
@@ -320,7 +321,10 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
'is_selected': context.get('is_selected', False),
'selectable': context.get('selectable', False),
'selected_groups_label': selected_groups_label,
'can_add': context.get('can_add', True),
'can_add': can_add,
# Generally speaking, "if you can add, you can delete". One exception is itembank (Problem Bank)
# which has its own separate "add" workflow but uses the normal delete workflow for its child blocks.
'can_delete': can_add or (root_xblock and root_xblock.scope_ids.block_type == "itembank" and can_edit),
'can_move': context.get('can_move', is_course),
'language': getattr(course, 'language', None),
'is_course': is_course,

View File

@@ -312,12 +312,12 @@ class GetItemTest(ItemTest):
resp = self.create_xblock(
parent_usage_key=split_test_usage_key,
category="html",
boilerplate="zooming_image.yaml",
boilerplate="latex_html.yaml",
)
self.assertEqual(resp.status_code, 200)
html, __ = self._get_container_preview(split_test_usage_key)
self.assertIn("Announcement", html)
self.assertIn("Zooming", html)
self.assertIn("LaTeX", html)
def test_split_test_edited(self):
"""
@@ -3184,13 +3184,13 @@ class TestComponentTemplates(CourseTestCase):
templates = get_component_templates(self.course)
button_names = [template["display_name"] for template in templates]
self.assertIn("Advanced", button_names)
self.assertEqual(len(templates[0]["templates"]), len(expected_xblocks))
self.assertEqual(len(templates[-1]["templates"]), len(expected_xblocks))
template_display_names = [
template["display_name"] for template in templates[0]["templates"]
template["display_name"] for template in templates[-1]["templates"]
]
self.assertEqual(template_display_names, expected_xblocks)
template_support_levels = [
template["support_level"] for template in templates[0]["templates"]
template["support_level"] for template in templates[-1]["templates"]
]
self.assertEqual(template_support_levels, expected_support_levels)

View File

@@ -7,11 +7,13 @@ import ddt
from opaque_keys.edx.keys import UsageKey
from rest_framework.test import APIClient
from openedx_tagging.core.tagging.models import Tag
from organizations.models import Organization
from xmodule.modulestore.django import contentstore, modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory, LibraryFactory
from cms.djangoapps.contentstore.utils import reverse_usage_url
from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangoapps.content_tagging import api as tagging_api
CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/"
@@ -182,7 +184,7 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
tagging_api.set_taxonomy_orgs(taxonomy_all_org, all_orgs=True)
for tag_value in ('tag_1', 'tag_2', 'tag_3', 'tag_4', 'tag_5', 'tag_6', 'tag_7'):
Tag.objects.create(taxonomy=taxonomy_all_org, value=tag_value)
tagging_api.add_tag_to_taxonomy(taxonomy_all_org, tag_value)
tagging_api.tag_object(
object_id=str(unit_key),
taxonomy=taxonomy_all_org,
@@ -391,6 +393,177 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged.
class ClipboardPasteFromV2LibraryTestCase(ModuleStoreTestCase):
"""
Test Clipboard Paste functionality with a "new" (as of Sumac) library
"""
def setUp(self):
"""
Set up a v2 Content Library and a library content block
"""
super().setUp()
self.client = APIClient()
self.client.login(username=self.user.username, password=self.user_password)
self.store = modulestore()
self.library = library_api.create_library(
org=Organization.objects.create(name="Test Org", short_name="CL-TEST"),
slug="lib",
title="Library",
)
self.lib_block_key = library_api.create_library_block(self.library.key, "problem", "p1").usage_key # v==1
library_api.set_library_block_olx(self.lib_block_key, """
<problem display_name="MCQ-published" max_attempts="1">
<multiplechoiceresponse>
<label>Q</label>
<choicegroup type="MultipleChoice">
<choice correct="false">Wrong</choice>
<choice correct="true">Right</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""") # v==2
library_api.publish_changes(self.library.key)
library_api.set_library_block_olx(self.lib_block_key, """
<problem display_name="MCQ-draft" max_attempts="5">
<multiplechoiceresponse>
<label>Q</label>
<choicegroup type="MultipleChoice">
<choice correct="false">Wrong</choice>
<choice correct="true">Right</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""") # v==3
lib_block_meta = library_api.get_library_block(self.lib_block_key)
assert lib_block_meta.published_version_num == 2
assert lib_block_meta.draft_version_num == 3
self.course = CourseFactory.create(display_name='Course')
taxonomy_all_org = tagging_api.create_taxonomy(
"test_taxonomy",
"Test Taxonomy",
export_id="ALL_ORGS",
)
tagging_api.set_taxonomy_orgs(taxonomy_all_org, all_orgs=True)
for tag_value in ('tag_1', 'tag_2', 'tag_3', 'tag_4', 'tag_5', 'tag_6', 'tag_7'):
tagging_api.add_tag_to_taxonomy(taxonomy_all_org, tag_value)
self.lib_block_tags = ['tag_1', 'tag_5']
tagging_api.tag_object(str(self.lib_block_key), taxonomy_all_org, self.lib_block_tags)
def test_paste_from_library_read_only_tags(self):
"""
When we copy a v2 lib block into a course, the dest block should have read-only copied tags.
"""
copy_response = self.client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(self.lib_block_key)}, format="json")
assert copy_response.status_code == 200
paste_response = self.client.post(XBLOCK_ENDPOINT, {
"parent_locator": str(self.course.usage_key),
"staged_content": "clipboard",
}, format="json")
assert paste_response.status_code == 200
new_block_key = paste_response.json()["locator"]
object_tags = tagging_api.get_object_tags(new_block_key)
assert len(object_tags) == len(self.lib_block_tags)
for object_tag in object_tags:
assert object_tag.value in self.lib_block_tags
assert object_tag.is_copied
def test_paste_from_library_copies_asset(self):
"""
Assets from a library component copied into a subdir of Files & Uploads.
"""
# This is the binary for a real, 1px webp file we need actual image
# data because contentstore will try to make a thumbnail and grab
# metadata.
webp_raw_data = b'RIFF\x16\x00\x00\x00WEBPVP8L\n\x00\x00\x00/\x00\x00\x00\x00E\xff#\xfa\x1f'
# First add the asset.
library_api.add_library_block_static_asset_file(
self.lib_block_key,
"static/1px.webp",
webp_raw_data,
) # v==4
# Now add the reference to the asset
library_api.set_library_block_olx(self.lib_block_key, """
<problem display_name="MCQ-draft" max_attempts="5">
<p>Including this totally real image: <img src="/static/1px.webp" /></p>
<multiplechoiceresponse>
<label>Q</label>
<choicegroup type="MultipleChoice">
<choice correct="false">Wrong</choice>
<choice correct="true">Right</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""") # v==5
copy_response = self.client.post(
CLIPBOARD_ENDPOINT,
{"usage_key": str(self.lib_block_key)},
format="json"
)
assert copy_response.status_code == 200
paste_response = self.client.post(XBLOCK_ENDPOINT, {
"parent_locator": str(self.course.usage_key),
"staged_content": "clipboard",
}, format="json")
assert paste_response.status_code == 200
new_block_key = UsageKey.from_string(paste_response.json()["locator"])
new_block = modulestore().get_item(new_block_key)
# Check that the substitution worked.
expected_import_path = f"components/{new_block_key.block_type}/{new_block_key.block_id}/1px.webp"
assert f"/static/{expected_import_path}" in new_block.data
# Check that the asset was copied over properly
image_asset = contentstore().find(
self.course.id.make_asset_key("asset", expected_import_path.replace('/', '_'))
)
assert image_asset.import_path == expected_import_path
assert image_asset.name == "1px.webp"
assert image_asset.length == len(webp_raw_data)
def test_paste_from_course_block_imported_from_library_creates_link(self):
"""
When we copy a course xblock which was imported or copied from v2 lib block into a course,
the dest block should be linked up to the original lib block.
"""
def _copy_paste_and_assert_link(key_to_copy):
copy_response = self.client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(key_to_copy)}, format="json")
assert copy_response.status_code == 200
paste_response = self.client.post(XBLOCK_ENDPOINT, {
"parent_locator": str(self.course.usage_key),
"staged_content": "clipboard",
}, format="json")
assert paste_response.status_code == 200
new_block_key = UsageKey.from_string(paste_response.json()["locator"])
new_block = modulestore().get_item(new_block_key)
assert new_block.upstream == str(self.lib_block_key)
assert new_block.upstream_version == 3
assert new_block.upstream_display_name == "MCQ-draft"
assert new_block.upstream_max_attempts == 5
return new_block_key
# first verify link for copied block from library
new_block_key = _copy_paste_and_assert_link(self.lib_block_key)
# next verify link for copied block from the pasted block
_copy_paste_and_assert_link(new_block_key)
class ClipboardPasteFromV1LibraryTestCase(ModuleStoreTestCase):
"""
Test Clipboard Paste functionality with legacy (v1) library content

View File

@@ -242,3 +242,61 @@ class ContainerPageTestCase(StudioPageTestCase, LibraryTestCase):
usage_key_string=str(self.vertical.location)
)
self.assertEqual(response.status_code, 200)
class ContainerEmbedPageTestCase(ContainerPageTestCase): # lint-amnesty, pylint: disable=test-inherits-tests
"""
Unit tests for the container embed page.
"""
def test_container_html(self):
assets_url = reverse(
'assets_handler', kwargs={'course_key_string': str(self.child_container.location.course_key)}
)
self._test_html_content(
self.child_container,
expected_section_tag=(
'<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
'data-locator="{0}" data-course-key="{0.course_key}" data-course-assets="{1}">'.format(
self.child_container.location, assets_url
)
),
)
def test_container_on_container_html(self):
"""
Create the scenario of an xblock with children (non-vertical) on the container page.
This should create a container page that is a child of another container page.
"""
draft_container = self._create_block(self.child_container, "wrapper", "Wrapper")
self._create_block(draft_container, "html", "Child HTML")
def test_container_html(xblock):
assets_url = reverse(
'assets_handler', kwargs={'course_key_string': str(draft_container.location.course_key)}
)
self._test_html_content(
xblock,
expected_section_tag=(
'<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
'data-locator="{0}" data-course-key="{0.course_key}" data-course-assets="{1}">'.format(
draft_container.location, assets_url
)
),
)
# Test the draft version of the container
test_container_html(draft_container)
# Now publish the unit and validate again
self.store.publish(self.vertical.location, self.user.id)
draft_container = self.store.get_item(draft_container.location)
test_container_html(draft_container)
def _test_html_content(self, xblock, expected_section_tag): # lint-amnesty, pylint: disable=arguments-differ
"""
Get the HTML for a container page and verify the section tag is correct
and the breadcrumbs trail is correct.
"""
html = self.get_page_html(xblock)
self.assertIn(expected_section_tag, html)

View File

@@ -433,13 +433,13 @@ class TestCourseIndexArchived(CourseTestCase):
@override_settings(FEATURES=FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API)
@ddt.data(
# Staff user has course staff access
(True, 'staff', None, 0, 21),
(False, 'staff', None, 0, 21),
(True, 'staff', None, 0, 23),
(False, 'staff', None, 0, 23),
# Base user has global staff access
(True, 'user', ORG, 2, 21),
(False, 'user', ORG, 2, 21),
(True, 'user', None, 2, 21),
(False, 'user', None, 2, 21),
(True, 'user', ORG, 2, 23),
(False, 'user', ORG, 2, 23),
(True, 'user', None, 2, 23),
(False, 'user', None, 2, 23),
)
@ddt.unpack
def test_separate_archived_courses(self, separate_archived_courses, username, org, mongo_queries, sql_queries):
@@ -464,13 +464,13 @@ class TestCourseIndexArchived(CourseTestCase):
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
@ddt.data(
# Staff user has course staff access
(True, 'staff', None, 0, 21),
(False, 'staff', None, 0, 21),
(True, 'staff', None, 0, 23),
(False, 'staff', None, 0, 23),
# Base user has global staff access
(True, 'user', ORG, 0, 21),
(False, 'user', ORG, 0, 21),
(True, 'user', None, 0, 21),
(False, 'user', None, 0, 21),
(True, 'user', ORG, 0, 23),
(False, 'user', ORG, 0, 23),
(True, 'user', None, 0, 23),
(False, 'user', None, 0, 23),
)
@ddt.unpack
def test_separate_archived_courses_with_home_page_course_v2_api(

View File

@@ -56,44 +56,44 @@ class UnitTestLibraries(CourseTestCase):
# Tests for /library/ - list and create libraries:
# When libraries are disabled, nobody can create libraries
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", False)
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", False)
def test_library_creator_status_libraries_not_enabled(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
self.assertEqual(user_can_create_library(nostaff_user, None), False)
# When creator group is disabled, non-staff users can create libraries
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_no_course_creator_role(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
self.assertEqual(user_can_create_library(nostaff_user, 'An Org'), True)
# When creator group is enabled, Non staff users cannot create libraries
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_for_enabled_creator_group_setting_for_non_staff_users(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
self.assertEqual(user_can_create_library(nostaff_user, None), False)
# Global staff can create libraries for any org, even ones that don't exist.
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_is_staff_user(self):
print(self.user.is_staff)
self.assertEqual(user_can_create_library(self.user, 'aNyOrg'), True)
# Global staff can create libraries for any org, but an org has to be supplied.
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_is_staff_user_no_org(self):
print(self.user.is_staff)
self.assertEqual(user_can_create_library(self.user, None), False)
# When creator groups are enabled, global staff can create libraries in any org
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_for_enabled_creator_group_setting_with_is_staff_user(self):
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
self.assertEqual(user_can_create_library(self.user, 'RandomOrg'), True)
# When creator groups are enabled, course creators can create libraries in any org.
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_course_creator_role_for_enabled_creator_group_setting(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
@@ -102,7 +102,7 @@ class UnitTestLibraries(CourseTestCase):
# When creator groups are enabled, course staff members can create libraries
# but only in the org they are course staff for.
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_course_staff_role_for_enabled_creator_group_setting(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
@@ -112,7 +112,7 @@ class UnitTestLibraries(CourseTestCase):
# When creator groups are enabled, course instructor members can create libraries
# but only in the org they are course staff for.
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_course_instructor_role_for_enabled_creator_group_setting(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
@@ -134,7 +134,7 @@ class UnitTestLibraries(CourseTestCase):
Ensure that the setting DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION as expected.
"""
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True):
with mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True):
with mock.patch.dict(
"django.conf.settings.FEATURES",
{
@@ -145,7 +145,7 @@ class UnitTestLibraries(CourseTestCase):
self.assertEqual(user_can_create_library(nostaff_user, 'SomEOrg'), expected_status)
@mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_COURSE_CREATION': True})
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_no_course_creator_role_and_disabled_nonstaff_course_creation(self):
"""
Ensure that `DISABLE_COURSE_CREATION` feature works with libraries as well.
@@ -161,7 +161,7 @@ class UnitTestLibraries(CourseTestCase):
self.assertEqual(get_response.status_code, 200)
self.assertEqual(post_response.status_code, 403)
@patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", False)
@mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_CONTENT_LIBRARIES': False})
def test_with_libraries_disabled(self):
"""
The library URLs should return 404 if libraries are disabled.
@@ -403,6 +403,8 @@ class UnitTestLibraries(CourseTestCase):
self.assertNotIn('advanced', templates)
self.assertNotIn('openassessment', templates)
self.assertNotIn('library', templates)
self.assertNotIn('library_v2', templates)
self.assertNotIn('itembank', templates)
def test_advanced_problem_types(self):
"""

View File

@@ -36,6 +36,7 @@ from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
from cms.djangoapps.contentstore.toggles import ENABLE_DEFAULT_ADVANCED_PROBLEM_EDITOR_FLAG
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.lib.ai_aside_summary_config import AiAsideSummaryConfig
from cms.lib.xblock.upstream_sync import BadUpstream, sync_from_upstream
from common.djangoapps.static_replace import replace_static_urls
from common.djangoapps.student.auth import (
has_studio_read_access,
@@ -539,7 +540,8 @@ def _create_block(request):
# Paste from the user's clipboard (content_staging app clipboard, not browser clipboard) into 'usage_key':
try:
created_xblock, notices = import_staged_content_from_user_clipboard(
parent_key=usage_key, request=request
parent_key=usage_key,
request=request,
)
except Exception: # pylint: disable=broad-except
log.exception(
@@ -556,6 +558,7 @@ def _create_block(request):
"locator": str(created_xblock.location),
"courseKey": str(created_xblock.location.course_key),
"static_file_notices": asdict(notices),
"upstreamRef": str(created_xblock.upstream),
})
category = request.json["category"]
@@ -585,12 +588,28 @@ def _create_block(request):
boilerplate=request.json.get("boilerplate"),
)
return JsonResponse(
{
"locator": str(created_block.location),
"courseKey": str(created_block.location.course_key),
}
)
response = {
"locator": str(created_block.location),
"courseKey": str(created_block.location.course_key),
}
# If it contains library_content_key, the block is being imported from a v2 library
# so it needs to be synced with upstream block.
if upstream_ref := request.json.get("library_content_key"):
try:
# Set `created_block.upstream` and then sync this with the upstream (library) version.
created_block.upstream = upstream_ref
sync_from_upstream(downstream=created_block, user=request.user)
except BadUpstream as exc:
_delete_item(created_block.location, request.user)
log.exception(
f"Could not sync to new block at '{created_block.usage_key}' "
f"using provided library_content_key='{upstream_ref}'"
)
return JsonResponse({"error": str(exc)}, status=400)
modulestore().update_item(created_block, request.user.id)
response['upstreamRef'] = upstream_ref
return JsonResponse(response)
def _get_source_index(source_usage_key, source_parent):

View File

@@ -1,311 +0,0 @@
"""
Tests for the maintenance app views.
"""
import json
import ddt
from django.conf import settings
from django.urls import reverse
from cms.djangoapps.contentstore.management.commands.utils import get_course_versions
from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
from openedx.features.announcements.models import Announcement
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
from .views import COURSE_KEY_ERROR_MESSAGES, MAINTENANCE_VIEWS
# This list contains URLs of all maintenance app views.
MAINTENANCE_URLS = [reverse(view['url']) for view in MAINTENANCE_VIEWS.values()]
class TestMaintenanceIndex(ModuleStoreTestCase):
"""
Tests for maintenance index view.
"""
def setUp(self):
super().setUp()
self.user = AdminFactory()
login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
self.assertTrue(login_success)
self.view_url = reverse('maintenance:maintenance_index')
def test_maintenance_index(self):
"""
Test that maintenance index view lists all the maintenance app views.
"""
response = self.client.get(self.view_url)
self.assertContains(response, 'Maintenance', status_code=200)
# Check that all the expected links appear on the index page.
for url in MAINTENANCE_URLS:
self.assertContains(response, url, status_code=200)
@ddt.ddt
class MaintenanceViewTestCase(ModuleStoreTestCase):
"""
Base class for maintenance view tests.
"""
view_url = ''
def setUp(self):
super().setUp()
self.user = AdminFactory()
login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
self.assertTrue(login_success)
def verify_error_message(self, data, error_message):
"""
Verify the response contains error message.
"""
response = self.client.post(self.view_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, error_message, status_code=200)
def tearDown(self):
"""
Reverse the setup.
"""
self.client.logout()
super().tearDown()
@ddt.ddt
class MaintenanceViewAccessTests(MaintenanceViewTestCase):
"""
Tests for access control of maintenance views.
"""
@ddt.data(*MAINTENANCE_URLS)
def test_require_login(self, url):
"""
Test that maintenance app requires user login.
"""
# Log out then try to retrieve the page
self.client.logout()
response = self.client.get(url)
# Expect a redirect to the login page
redirect_url = '{login_url}?next={original_url}'.format(
login_url=settings.LOGIN_URL,
original_url=url,
)
# Studio login redirects to LMS login
self.assertRedirects(response, redirect_url, target_status_code=302)
@ddt.data(*MAINTENANCE_URLS)
def test_global_staff_access(self, url):
"""
Test that all maintenance app views are accessible to global staff user.
"""
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
@ddt.data(*MAINTENANCE_URLS)
def test_non_global_staff_access(self, url):
"""
Test that all maintenance app views are not accessible to non-global-staff user.
"""
user = UserFactory(username='test', email='test@example.com', password=self.TEST_PASSWORD)
login_success = self.client.login(username=user.username, password=self.TEST_PASSWORD)
self.assertTrue(login_success)
response = self.client.get(url)
self.assertContains(
response,
f'Must be {settings.PLATFORM_NAME} staff to perform this action.',
status_code=403
)
@ddt.ddt
class TestForcePublish(MaintenanceViewTestCase):
"""
Tests for the force publish view.
"""
def setUp(self):
super().setUp()
self.view_url = reverse('maintenance:force_publish_course')
def setup_test_course(self):
"""
Creates the course and add some changes to it.
Returns:
course: a course object
"""
course = CourseFactory.create()
# Add some changes to course
chapter = BlockFactory.create(category='chapter', parent_location=course.location)
self.store.create_child(
self.user.id,
chapter.location,
'html',
block_id='html_component'
)
# verify that course has changes.
self.assertTrue(self.store.has_changes(self.store.get_item(course.location)))
return course
@ddt.data(
('', COURSE_KEY_ERROR_MESSAGES['empty_course_key']),
('edx', COURSE_KEY_ERROR_MESSAGES['invalid_course_key']),
('course-v1:e+d+X', COURSE_KEY_ERROR_MESSAGES['course_key_not_found']),
)
@ddt.unpack
def test_invalid_course_key_messages(self, course_key, error_message):
"""
Test all error messages for invalid course keys.
"""
# validate that course key contains error message
self.verify_error_message(
data={'course-id': course_key},
error_message=error_message
)
def test_already_published(self):
"""
Test that when a course is forcefully publish, we get a 'course is already published' message.
"""
course = self.setup_test_course()
# publish the course
source_store = modulestore()._get_modulestore_for_courselike(course.id) # pylint: disable=protected-access
source_store.force_publish_course(course.id, self.user.id, commit=True)
# now course is published, we should get `already published course` error.
self.verify_error_message(
data={'course-id': str(course.id)},
error_message='Course is already in published state.'
)
def verify_versions_are_different(self, course):
"""
Verify draft and published versions point to different locations.
Arguments:
course (object): a course object.
"""
# get draft and publish branch versions
versions = get_course_versions(str(course.id))
# verify that draft and publish point to different versions
self.assertNotEqual(versions['draft-branch'], versions['published-branch'])
def get_force_publish_course_response(self, course):
"""
Get force publish the course response.
Arguments:
course (object): a course object.
Returns:
response : response from force publish post view.
"""
# Verify versions point to different locations initially
self.verify_versions_are_different(course)
# force publish course view
data = {
'course-id': str(course.id)
}
response = self.client.post(self.view_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
response_data = json.loads(response.content.decode('utf-8'))
return response_data
def test_force_publish_dry_run(self):
"""
Test that dry run does not publishes the course but shows possible outcome if force published is executed.
"""
course = self.setup_test_course()
response = self.get_force_publish_course_response(course)
self.assertIn('current_versions', response)
# verify that course still has changes as we just dry ran force publish course.
self.assertTrue(self.store.has_changes(self.store.get_item(course.location)))
# verify that both branch versions are still different
self.verify_versions_are_different(course)
@ddt.ddt
class TestAnnouncementsViews(MaintenanceViewTestCase):
"""
Tests for the announcements edit view.
"""
def setUp(self):
super().setUp()
self.admin = AdminFactory.create(
email='staff@edx.org',
username='admin',
password=self.TEST_PASSWORD
)
self.client.login(username=self.admin.username, password=self.TEST_PASSWORD)
self.non_staff_user = UserFactory.create(
email='test@edx.org',
username='test',
password=self.TEST_PASSWORD
)
def test_index(self):
"""
Test create announcement view
"""
url = reverse("maintenance:announcement_index")
response = self.client.get(url)
self.assertContains(response, '<div class="announcement-container">')
def test_create(self):
"""
Test create announcement view
"""
url = reverse("maintenance:announcement_create")
self.client.post(url, {"content": "Test Create Announcement", "active": True})
result = Announcement.objects.filter(content="Test Create Announcement").exists()
self.assertTrue(result)
def test_edit(self):
"""
Test edit announcement view
"""
announcement = Announcement.objects.create(content="test")
announcement.save()
url = reverse("maintenance:announcement_edit", kwargs={"pk": announcement.pk})
response = self.client.get(url)
self.assertContains(response, '<div class="wrapper-form announcement-container">')
self.client.post(url, {"content": "Test Edit Announcement", "active": True})
announcement = Announcement.objects.get(pk=announcement.pk)
self.assertEqual(announcement.content, "Test Edit Announcement")
def test_delete(self):
"""
Test delete announcement view
"""
announcement = Announcement.objects.create(content="Test Delete")
announcement.save()
url = reverse("maintenance:announcement_delete", kwargs={"pk": announcement.pk})
self.client.post(url)
result = Announcement.objects.filter(content="Test Edit Announcement").exists()
self.assertFalse(result)
def _test_403(self, viewname, kwargs=None):
url = reverse("maintenance:%s" % viewname, kwargs=kwargs)
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_authorization(self):
self.client.login(username=self.non_staff_user, password=self.TEST_PASSWORD)
announcement = Announcement.objects.create(content="Test Delete")
announcement.save()
self._test_403("announcement_index")
self._test_403("announcement_create")
self._test_403("announcement_edit", {"pk": announcement.pk})
self._test_403("announcement_delete", {"pk": announcement.pk})

View File

@@ -1,25 +0,0 @@
"""
URLs for the maintenance app.
"""
from django.urls import path, re_path
from .views import (
AnnouncementCreateView,
AnnouncementDeleteView,
AnnouncementEditView,
AnnouncementIndexView,
ForcePublishCourseView,
MaintenanceIndexView
)
app_name = 'cms.djangoapps.maintenance'
urlpatterns = [
path('', MaintenanceIndexView.as_view(), name='maintenance_index'),
re_path(r'^force_publish_course/?$', ForcePublishCourseView.as_view(), name='force_publish_course'),
re_path(r'^announcements/(?P<page>\d+)?$', AnnouncementIndexView.as_view(), name='announcement_index'),
path('announcements/create', AnnouncementCreateView.as_view(), name='announcement_create'),
re_path(r'^announcements/edit/(?P<pk>\d+)?$', AnnouncementEditView.as_view(), name='announcement_edit'),
path('announcements/delete/<int:pk>', AnnouncementDeleteView.as_view(), name='announcement_delete'),
]

View File

@@ -1,301 +0,0 @@
"""
Views for the maintenance app.
"""
import logging
from django.core.validators import ValidationError
from django.db import transaction
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views.generic import View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from django.views.generic.list import ListView
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from cms.djangoapps.contentstore.management.commands.utils import get_course_versions
from common.djangoapps.edxmako.shortcuts import render_to_response
from common.djangoapps.util.json_request import JsonResponse
from common.djangoapps.util.views import require_global_staff
from openedx.features.announcements.forms import AnnouncementForm
from openedx.features.announcements.models import Announcement
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
log = logging.getLogger(__name__)
# This dict maintains all the views that will be used Maintenance app.
MAINTENANCE_VIEWS = {
'force_publish_course': {
'url': 'maintenance:force_publish_course',
'name': _('Force Publish Course'),
'slug': 'force_publish_course',
'description': _(
'Sometimes the draft and published branches of a course can get out of sync. Force publish course command '
'resets the published branch of a course to point to the draft branch, effectively force publishing the '
'course. This view dry runs the force publish command'
),
},
'announcement_index': {
'url': 'maintenance:announcement_index',
'name': _('Edit Announcements'),
'slug': 'announcement_index',
'description': _(
'This view shows the announcement editor to create or alter announcements that are shown on the right'
'side of the dashboard.'
),
},
}
COURSE_KEY_ERROR_MESSAGES = {
'empty_course_key': _('Please provide course id.'),
'invalid_course_key': _('Invalid course key.'),
'course_key_not_found': _('No matching course found.')
}
class MaintenanceIndexView(View):
"""
Index view for maintenance dashboard, used by global staff.
This view lists some commands/tasks that can be used to dry run or execute directly.
"""
@method_decorator(require_global_staff)
def get(self, request):
"""Render the maintenance index view. """
return render_to_response('maintenance/index.html', {
'views': MAINTENANCE_VIEWS,
})
class MaintenanceBaseView(View):
"""
Base class for Maintenance views.
"""
template = 'maintenance/container.html'
def __init__(self, view=None):
super().__init__()
self.context = {
'view': view if view else '',
'form_data': {},
'error': False,
'msg': ''
}
def render_response(self):
"""
A short method to render_to_response that renders response.
"""
if self.request.headers.get('x-requested-with') == 'XMLHttpRequest':
return JsonResponse(self.context)
return render_to_response(self.template, self.context)
@method_decorator(require_global_staff)
def get(self, request):
"""
Render get view.
"""
return self.render_response()
def validate_course_key(self, course_key, branch=ModuleStoreEnum.BranchName.draft):
"""
Validates the course_key that would be used by maintenance app views.
Arguments:
course_key (string): a course key
branch: a course locator branch, default value is ModuleStoreEnum.BranchName.draft .
values can be either ModuleStoreEnum.BranchName.draft or ModuleStoreEnum.BranchName.published.
Returns:
course_usage_key (CourseLocator): course usage locator
"""
if not course_key:
raise ValidationError(COURSE_KEY_ERROR_MESSAGES['empty_course_key'])
course_usage_key = CourseKey.from_string(course_key)
if not modulestore().has_course(course_usage_key):
raise ItemNotFoundError(COURSE_KEY_ERROR_MESSAGES['course_key_not_found'])
# get branch specific locator
course_usage_key = course_usage_key.for_branch(branch)
return course_usage_key
class ForcePublishCourseView(MaintenanceBaseView):
"""
View for force publishing state of the course, used by the global staff.
This view uses `force_publish_course` method of modulestore which publishes the draft state of the course. After
the course has been forced published, both draft and publish draft point to same location.
"""
def __init__(self):
super().__init__(MAINTENANCE_VIEWS['force_publish_course'])
self.context.update({
'current_versions': [],
'updated_versions': [],
'form_data': {
'course_id': '',
'is_dry_run': True
}
})
def get_course_branch_versions(self, versions):
"""
Returns a dict containing unicoded values of draft and published draft versions.
"""
return {
'draft-branch': str(versions['draft-branch']),
'published-branch': str(versions['published-branch'])
}
@transaction.atomic
@method_decorator(require_global_staff)
def post(self, request):
"""
This method force publishes a course if dry-run argument is not selected. If dry-run is selected, this view
shows possible outcome if the `force_publish_course` modulestore method is executed.
Arguments:
course_id (string): a request parameter containing course id
is_dry_run (string): a request parameter containing dry run value.
It is obtained from checkbox so it has either values 'on' or ''.
"""
course_id = request.POST.get('course-id')
self.context.update({
'form_data': {
'course_id': course_id
}
})
try:
course_usage_key = self.validate_course_key(course_id)
except InvalidKeyError:
self.context['error'] = True
self.context['msg'] = COURSE_KEY_ERROR_MESSAGES['invalid_course_key']
except ItemNotFoundError as exc:
self.context['error'] = True
self.context['msg'] = str(exc)
except ValidationError as exc:
self.context['error'] = True
self.context['msg'] = str(exc)
if self.context['error']:
return self.render_response()
source_store = modulestore()._get_modulestore_for_courselike(course_usage_key) # pylint: disable=protected-access
if not hasattr(source_store, 'force_publish_course'):
self.context['msg'] = _('Force publishing course is not supported with old mongo courses.')
log.warning(
'Force publishing course is not supported with old mongo courses. \
%s attempted to force publish the course %s.',
request.user,
course_id,
exc_info=True
)
return self.render_response()
current_versions = self.get_course_branch_versions(get_course_versions(course_id))
# if publish and draft are NOT different
if current_versions['published-branch'] == current_versions['draft-branch']:
self.context['msg'] = _('Course is already in published state.')
log.warning(
'Course is already in published state. %s attempted to force publish the course %s.',
request.user,
course_id,
exc_info=True
)
return self.render_response()
self.context['current_versions'] = current_versions
log.info(
'%s dry ran force publish the course %s.',
request.user,
course_id,
exc_info=True
)
return self.render_response()
class AnnouncementBaseView(View):
"""
Base view for Announcements pages
"""
@method_decorator(require_global_staff)
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
class AnnouncementIndexView(ListView, MaintenanceBaseView):
"""
View for viewing the announcements shown on the dashboard, used by the global staff.
"""
model = Announcement
object_list = Announcement.objects.order_by('-active')
context_object_name = 'announcement_list'
paginate_by = 8
def __init__(self):
super().__init__(MAINTENANCE_VIEWS['announcement_index'])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['view'] = MAINTENANCE_VIEWS['announcement_index']
return context
@method_decorator(require_global_staff)
def get(self, request, *args, **kwargs):
context = self.get_context_data()
return render_to_response(self.template, context)
class AnnouncementEditView(UpdateView, AnnouncementBaseView):
"""
View for editing an announcement.
"""
model = Announcement
form_class = AnnouncementForm
success_url = reverse_lazy('maintenance:announcement_index')
template_name = '/maintenance/_announcement_edit.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['action_url'] = reverse('maintenance:announcement_edit', kwargs={'pk': context['announcement'].pk})
return context
class AnnouncementCreateView(CreateView, AnnouncementBaseView):
"""
View for creating an announcement.
"""
model = Announcement
form_class = AnnouncementForm
success_url = reverse_lazy('maintenance:announcement_index')
template_name = '/maintenance/_announcement_edit.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['action_url'] = reverse('maintenance:announcement_create')
return context
class AnnouncementDeleteView(DeleteView, AnnouncementBaseView):
"""
View for deleting an announcement.
"""
model = Announcement
success_url = reverse_lazy('maintenance:announcement_index')
template_name = '/maintenance/_announcement_delete.html'

View File

@@ -122,11 +122,22 @@ from lms.envs.common import (
# Password Validator Settings
AUTH_PASSWORD_VALIDATORS
)
from lms.envs.common import (
USE_EXTRACTED_WORD_CLOUD_BLOCK,
USE_EXTRACTED_ANNOTATABLE_BLOCK,
USE_EXTRACTED_POLL_QUESTION_BLOCK,
USE_EXTRACTED_LTI_BLOCK,
USE_EXTRACTED_HTML_BLOCK,
USE_EXTRACTED_DISCUSSION_BLOCK,
USE_EXTRACTED_PROBLEM_BLOCK,
USE_EXTRACTED_VIDEO_BLOCK,
)
from path import Path as path
from django.urls import reverse_lazy
from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin
from cms.lib.xblock.authoring_mixin import AuthoringMixin
from cms.lib.xblock.upstream_sync import UpstreamSyncMixin
from xmodule.modulestore.edit_info import EditInfoMixin
from openedx.core.djangoapps.theming.helpers_dirs import (
get_themes_unchecked,
@@ -565,6 +576,14 @@ FEATURES = {
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2024-04-10
'BADGES_ENABLED': False,
# .. toggle_name: FEATURES['IN_CONTEXT_DISCUSSION_ENABLED_DEFAULT']
# .. toggle_implementation: DjangoSetting
# .. toggle_default: True
# .. toggle_description: Set to False to disable in-context discussion for units by default.
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2024-09-02
'IN_CONTEXT_DISCUSSION_ENABLED_DEFAULT': True,
}
# .. toggle_name: ENABLE_COPPA_COMPLIANCE
@@ -663,6 +682,7 @@ CMS_ROOT = REPO_ROOT / "cms"
LMS_ROOT = REPO_ROOT / "lms"
ENV_ROOT = REPO_ROOT.dirname() # virtualenv dir /edx-platform is in
COURSES_ROOT = ENV_ROOT / "data"
XMODULE_ROOT = REPO_ROOT / "xmodule"
GITHUB_REPO_ROOT = ENV_ROOT / "data"
@@ -995,6 +1015,7 @@ XBLOCK_MIXINS = (
XModuleMixin,
EditInfoMixin,
AuthoringMixin,
UpstreamSyncMixin,
)
# .. setting_name: XBLOCK_EXTRA_MIXINS
@@ -1268,6 +1289,10 @@ STATIC_ROOT = os.environ.get('STATIC_ROOT_CMS', ENV_ROOT / 'staticfiles' / 'stud
STATICFILES_DIRS = [
COMMON_ROOT / "static",
PROJECT_ROOT / "static",
# Temporarily adding the following static path as we are migrating the built-in blocks' Sass to vanilla CSS.
# Once all of the built-in blocks are extracted from edx-platform, we can remove this static path.
# Relevant ticket: https://github.com/openedx/edx-platform/issues/35300
XMODULE_ROOT / "static",
]
# Locale/Internationalization
@@ -1405,6 +1430,12 @@ PIPELINE['STYLESHEETS'] = {
],
'output_filename': 'css/cms-style-xmodule-annotations.css',
},
'course-unit-mfe-iframe-bundle': {
'source_filenames': [
'css/course-unit-mfe-iframe-bundle.css',
],
'output_filename': 'css/course-unit-mfe-iframe-bundle.css',
},
}
base_vendor_js = [
@@ -1640,6 +1671,9 @@ INSTALLED_APPS = [
'corsheaders',
'openedx.core.djangoapps.cors_csrf',
# Provides the 'django_markup' template library so we can use 'interpolate_html' in django templates
'xss_utils',
# History tables
'simple_history',
@@ -1671,8 +1705,6 @@ INSTALLED_APPS = [
# New (Learning-Core-based) XBlock runtime
'openedx.core.djangoapps.xblock.apps.StudioXBlockAppConfig',
# Maintenance tools
'cms.djangoapps.maintenance',
'openedx.core.djangoapps.util.apps.UtilConfig',
# Tracking
@@ -2710,7 +2742,7 @@ PASSWORD_RESET_IP_RATE = '1/m'
PASSWORD_RESET_EMAIL_RATE = '2/h'
######################## Setting for content libraries ########################
MAX_BLOCKS_PER_CONTENT_LIBRARY = 1000
MAX_BLOCKS_PER_CONTENT_LIBRARY = 100_000
################# Student Verification #################
VERIFY_STUDENT = {

View File

@@ -0,0 +1,425 @@
"""
Test CMS's upstream->downstream syncing system
"""
import ddt
from organizations.api import ensure_organization
from organizations.models import Organization
from cms.lib.xblock.upstream_sync import (
UpstreamLink,
sync_from_upstream, decline_sync, fetch_customizable_fields, sever_upstream_link,
NoUpstream, BadUpstream, BadDownstream,
)
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content_libraries import api as libs
from openedx.core.djangoapps.content_tagging import api as tagging_api
from openedx.core.djangoapps.xblock import api as xblock
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
@ddt.ddt
class UpstreamTestCase(ModuleStoreTestCase):
"""
Tests the upstream_sync mixin, data object, and Python APIs.
"""
def setUp(self):
"""
Create a simple course with one unit, and simple V2 library with two blocks.
"""
super().setUp()
course = CourseFactory.create()
chapter = BlockFactory.create(category='chapter', parent=course)
sequential = BlockFactory.create(category='sequential', parent=chapter)
self.unit = BlockFactory.create(category='vertical', parent=sequential)
ensure_organization("TestX")
self.library = libs.create_library(
org=Organization.objects.get(short_name="TestX"),
slug="TestLib",
title="Test Upstream Library",
)
self.upstream_key = libs.create_library_block(self.library.key, "html", "test-upstream").usage_key
libs.create_library_block(self.library.key, "video", "video-upstream")
upstream = xblock.load_block(self.upstream_key, self.user)
upstream.display_name = "Upstream Title V2"
upstream.data = "<html><body>Upstream content V2</body></html>"
upstream.save()
libs.publish_changes(self.library.key, self.user.id)
self.taxonomy_all_org = tagging_api.create_taxonomy(
"test_taxonomy",
"Test Taxonomy",
export_id="ALL_ORGS",
)
tagging_api.set_taxonomy_orgs(self.taxonomy_all_org, all_orgs=True)
for tag_value in ('tag_1', 'tag_2', 'tag_3', 'tag_4', 'tag_5', 'tag_6', 'tag_7'):
tagging_api.add_tag_to_taxonomy(self.taxonomy_all_org, tag_value)
self.upstream_tags = ['tag_1', 'tag_5']
tagging_api.tag_object(str(self.upstream_key), self.taxonomy_all_org, self.upstream_tags)
def test_sync_bad_downstream(self):
"""
Syncing into an unsupported downstream (such as a another Content Library block) raises BadDownstream, but
doesn't affect the block.
"""
downstream_lib_block_key = libs.create_library_block(self.library.key, "html", "bad-downstream").usage_key
downstream_lib_block = xblock.load_block(downstream_lib_block_key, self.user)
downstream_lib_block.display_name = "Another lib block"
downstream_lib_block.data = "<html>another lib block</html>"
downstream_lib_block.upstream = str(self.upstream_key)
downstream_lib_block.save()
with self.assertRaises(BadDownstream):
sync_from_upstream(downstream_lib_block, self.user)
assert downstream_lib_block.display_name == "Another lib block"
assert downstream_lib_block.data == "<html>another lib block</html>"
def test_sync_no_upstream(self):
"""
Trivial case: Syncing a block with no upstream is a no-op
"""
block = BlockFactory.create(category='html', parent=self.unit)
block.display_name = "Block Title"
block.data = "Block content"
with self.assertRaises(NoUpstream):
sync_from_upstream(block, self.user)
assert block.display_name == "Block Title"
assert block.data == "Block content"
assert not block.upstream_display_name
@ddt.data(
("not-a-key-at-all", ".*is malformed.*"),
("course-v1:Oops+ItsA+CourseKey", ".*is malformed.*"),
("block-v1:The+Wrong+KindOfUsageKey+type@html+block@nope", ".*is malformed.*"),
("lb:TestX:NoSuchLib:html:block-id", ".*not found in the system.*"),
("lb:TestX:TestLib:video:should-be-html-but-is-a-video", ".*type mismatch.*"),
("lb:TestX:TestLib:html:no-such-html", ".*not found in the system.*"),
)
@ddt.unpack
def test_sync_bad_upstream(self, upstream, message_regex):
"""
Syncing with a bad upstream raises BadUpstream, but doesn't affect the block
"""
block = BlockFactory.create(category='html', parent=self.unit, upstream=upstream)
block.display_name = "Block Title"
block.data = "Block content"
with self.assertRaisesRegex(BadUpstream, message_regex):
sync_from_upstream(block, self.user)
assert block.display_name == "Block Title"
assert block.data == "Block content"
assert not block.upstream_display_name
def test_sync_not_accessible(self):
"""
Syncing with an block that exists, but is inaccessible, raises BadUpstream
"""
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
user_who_cannot_read_upstream = UserFactory.create(username="rando", is_staff=False, is_superuser=False)
with self.assertRaisesRegex(BadUpstream, ".*could not be loaded.*") as exc:
sync_from_upstream(downstream, user_who_cannot_read_upstream)
def test_sync_updates_happy_path(self):
"""
Can we sync updates from a content library block to a linked out-of-date course block?
"""
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
# Initial sync
sync_from_upstream(downstream, self.user)
assert downstream.upstream_version == 2 # Library blocks start at version 2 (v1 is the empty new block)
assert downstream.upstream_display_name == "Upstream Title V2"
assert downstream.display_name == "Upstream Title V2"
assert downstream.data == "<html><body>Upstream content V2</body></html>"
# Verify tags
object_tags = tagging_api.get_object_tags(str(downstream.location))
assert len(object_tags) == len(self.upstream_tags)
for object_tag in object_tags:
assert object_tag.value in self.upstream_tags
# Upstream updates
upstream = xblock.load_block(self.upstream_key, self.user)
upstream.display_name = "Upstream Title V3"
upstream.data = "<html><body>Upstream content V3</body></html>"
upstream.save()
new_upstream_tags = self.upstream_tags + ['tag_2', 'tag_3']
tagging_api.tag_object(str(self.upstream_key), self.taxonomy_all_org, new_upstream_tags)
# Assert that un-published updates are not yet pulled into downstream
sync_from_upstream(downstream, self.user)
assert downstream.upstream_version == 2 # Library blocks start at version 2 (v1 is the empty new block)
assert downstream.upstream_display_name == "Upstream Title V2"
assert downstream.display_name == "Upstream Title V2"
assert downstream.data == "<html><body>Upstream content V2</body></html>"
# Publish changes
libs.publish_changes(self.library.key, self.user.id)
# Follow-up sync. Assert that updates are pulled into downstream.
sync_from_upstream(downstream, self.user)
assert downstream.upstream_version == 3
assert downstream.upstream_display_name == "Upstream Title V3"
assert downstream.display_name == "Upstream Title V3"
assert downstream.data == "<html><body>Upstream content V3</body></html>"
# Verify tags
object_tags = tagging_api.get_object_tags(str(downstream.location))
assert len(object_tags) == len(new_upstream_tags)
for object_tag in object_tags:
assert object_tag.value in new_upstream_tags
def test_sync_updates_to_modified_content(self):
"""
If we sync to modified content, will it preserve customizable fields, but overwrite the rest?
"""
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
# Initial sync
sync_from_upstream(downstream, self.user)
assert downstream.upstream_display_name == "Upstream Title V2"
assert downstream.display_name == "Upstream Title V2"
assert downstream.data == "<html><body>Upstream content V2</body></html>"
# Upstream updates
upstream = xblock.load_block(self.upstream_key, self.user)
upstream.display_name = "Upstream Title V3"
upstream.data = "<html><body>Upstream content V3</body></html>"
upstream.save()
libs.publish_changes(self.library.key, self.user.id)
# Downstream modifications
downstream.display_name = "Downstream Title Override" # "safe" customization
downstream.data = "Downstream content override" # "unsafe" override
downstream.save()
# Follow-up sync. Assert that updates are pulled into downstream, but customizations are saved.
sync_from_upstream(downstream, self.user)
assert downstream.upstream_display_name == "Upstream Title V3"
assert downstream.display_name == "Downstream Title Override" # "safe" customization survives
assert downstream.data == "<html><body>Upstream content V3</body></html>" # "unsafe" override is gone
# For the Content Libraries Relaunch Beta, we do not yet need to support this edge case.
# See "PRESERVING DOWNSTREAM CUSTOMIZATIONS and RESTORING UPSTREAM DEFAULTS" in cms/lib/xblock/upstream_sync.py.
#
# def test_sync_to_downstream_with_subtle_customization(self):
# """
# Edge case: If our downstream customizes a field, but then the upstream is changed to match the
# customization do we still remember that the downstream field is customized? That is,
# if the upstream later changes again, do we retain the downstream customization (rather than
# following the upstream update?)
# """
# # Start with an uncustomized downstream block.
# downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
# sync_from_upstream(downstream, self.user)
# assert downstream.downstream_customized == []
# assert downstream.display_name == downstream.upstream_display_name == "Upstream Title V2"
#
# # Then, customize our downstream title.
# downstream.display_name = "Title V3"
# downstream.save()
# assert downstream.downstream_customized == ["display_name"]
#
# # Syncing should retain the customization.
# sync_from_upstream(downstream, self.user)
# assert downstream.upstream_version == 2
# assert downstream.upstream_display_name == "Upstream Title V2"
# assert downstream.display_name == "Title V3"
#
# # Whoa, look at that, the upstream has updated itself to the exact same title...
# upstream = xblock.load_block(self.upstream_key, self.user)
# upstream.display_name = "Title V3"
# upstream.save()
#
# # ...which is reflected when we sync.
# sync_from_upstream(downstream, self.user)
# assert downstream.upstream_version == 3
# assert downstream.upstream_display_name == downstream.display_name == "Title V3"
#
# # But! Our downstream knows that its title is still customized.
# assert downstream.downstream_customized == ["display_name"]
# # So, if the upstream title changes again...
# upstream.display_name = "Title V4"
# upstream.save()
#
# # ...then the downstream title should remain put.
# sync_from_upstream(downstream, self.user)
# assert downstream.upstream_version == 4
# assert downstream.upstream_display_name == "Title V4"
# assert downstream.display_name == "Title V3"
#
# # Finally, if we "de-customize" the display_name field, then it should go back to syncing normally.
# downstream.downstream_customized = []
# upstream.display_name = "Title V5"
# upstream.save()
# sync_from_upstream(downstream, self.user)
# assert downstream.upstream_version == 5
# assert downstream.upstream_display_name == downstream.display_name == "Title V5"
@ddt.data(None, "Title From Some Other Upstream Version")
def test_fetch_customizable_fields(self, initial_upstream_display_name):
"""
Can we fetch a block's upstream field values without syncing it?
Test both with and without a pre-"fetched" upstrema values on the downstream.
"""
downstream = BlockFactory.create(category='html', parent=self.unit)
downstream.upstream_display_name = initial_upstream_display_name
downstream.display_name = "Some Title"
downstream.data = "<html><data>Some content</data></html>"
# Note that we're not linked to any upstream. fetch_customizable_fields shouldn't care.
assert not downstream.upstream
assert not downstream.upstream_version
# fetch!
upstream = xblock.load_block(self.upstream_key, self.user)
fetch_customizable_fields(upstream=upstream, downstream=downstream, user=self.user)
# Ensure: fetching doesn't affect the upstream link (or lack thereof).
assert not downstream.upstream
assert not downstream.upstream_version
# Ensure: fetching doesn't affect actual content or settings.
assert downstream.display_name == "Some Title"
assert downstream.data == "<html><data>Some content</data></html>"
# Ensure: fetching DOES set the upstream_* fields.
assert downstream.upstream_display_name == "Upstream Title V2"
def test_prompt_and_decline_sync(self):
"""
Is the user prompted for sync when it's available? Does declining remove the prompt until a new sync is ready?
"""
# Initial conditions (pre-sync)
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
link = UpstreamLink.get_for_block(downstream)
assert link.version_synced is None
assert link.version_declined is None
assert link.version_available == 2 # Library block with content starts at version 2
assert link.ready_to_sync is True
# Initial sync to V2
sync_from_upstream(downstream, self.user)
link = UpstreamLink.get_for_block(downstream)
assert link.version_synced == 2
assert link.version_declined is None
assert link.version_available == 2
assert link.ready_to_sync is False
# Upstream updated to V3, but not yet published
upstream = xblock.load_block(self.upstream_key, self.user)
upstream.data = "<html><body>Upstream content V3</body></html>"
upstream.save()
link = UpstreamLink.get_for_block(downstream)
assert link.version_synced == 2
assert link.version_declined is None
assert link.version_available == 2
assert link.ready_to_sync is False
# Publish changes
libs.publish_changes(self.library.key, self.user.id)
link = UpstreamLink.get_for_block(downstream)
assert link.version_synced == 2
assert link.version_declined is None
assert link.version_available == 3
assert link.ready_to_sync is True
# Decline to sync to V3 -- ready_to_sync becomes False.
decline_sync(downstream)
link = UpstreamLink.get_for_block(downstream)
assert link.version_synced == 2
assert link.version_declined == 3
assert link.version_available == 3
assert link.ready_to_sync is False
# Upstream updated to V4 -- ready_to_sync becomes True again.
upstream = xblock.load_block(self.upstream_key, self.user)
upstream.data = "<html><body>Upstream content V4</body></html>"
upstream.save()
libs.publish_changes(self.library.key, self.user.id)
link = UpstreamLink.get_for_block(downstream)
assert link.version_synced == 2
assert link.version_declined == 3
assert link.version_available == 4
assert link.ready_to_sync is True
def test_sever_upstream_link(self):
"""
Does sever_upstream_link correctly disconnect a block from its upstream?
"""
# Start with a course block that is linked+synced to a content library block.
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
sync_from_upstream(downstream, self.user)
# (sanity checks)
assert downstream.upstream == str(self.upstream_key)
assert downstream.upstream_version == 2
assert downstream.upstream_display_name == "Upstream Title V2"
assert downstream.display_name == "Upstream Title V2"
assert downstream.data == "<html><body>Upstream content V2</body></html>"
assert downstream.copied_from_block is None
# Now, disconnect the course block.
sever_upstream_link(downstream)
# All upstream metadata has been wiped out.
assert downstream.upstream is None
assert downstream.upstream_version is None
assert downstream.upstream_display_name is None
# BUT, the content which was synced into the upstream remains.
assert downstream.display_name == "Upstream Title V2"
assert downstream.data == "<html><body>Upstream content V2</body></html>"
# AND, we have recorded the old upstream as our copied_from_block.
assert downstream.copied_from_block == str(self.upstream_key)
def test_sync_library_block_tags(self):
upstream_lib_block_key = libs.create_library_block(self.library.key, "html", "upstream").usage_key
upstream_lib_block = xblock.load_block(upstream_lib_block_key, self.user)
upstream_lib_block.display_name = "Another lib block"
upstream_lib_block.data = "<html>another lib block</html>"
upstream_lib_block.save()
libs.publish_changes(self.library.key, self.user.id)
expected_tags = self.upstream_tags
tagging_api.tag_object(str(upstream_lib_block_key), self.taxonomy_all_org, expected_tags)
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(upstream_lib_block_key))
# Initial sync
sync_from_upstream(downstream, self.user)
# Verify tags
object_tags = tagging_api.get_object_tags(str(downstream.location))
assert len(object_tags) == len(expected_tags)
for object_tag in object_tags:
assert object_tag.value in expected_tags
# Upstream updates
upstream_lib_block.display_name = "Upstream Title V3"
upstream_lib_block.data = "<html><body>Upstream content V3</body></html>"
upstream_lib_block.save()
new_upstream_tags = self.upstream_tags + ['tag_2', 'tag_3']
tagging_api.tag_object(str(upstream_lib_block_key), self.taxonomy_all_org, new_upstream_tags)
# Follow-up sync.
sync_from_upstream(downstream, self.user)
#Verify tags
object_tags = tagging_api.get_object_tags(str(downstream.location))
assert len(object_tags) == len(new_upstream_tags)
for object_tag in object_tags:
assert object_tag.value in new_upstream_tags

View File

@@ -0,0 +1,492 @@
"""
Synchronize content and settings from upstream blocks to their downstream usages.
At the time of writing, we assume that for any upstream-downstream linkage:
* The upstream is a Component from a Learning Core-backed Content Library.
* The downstream is a block of matching type in a SplitModuleStore-backed Course.
* They are both on the same Open edX instance.
HOWEVER, those assumptions may loosen in the future. So, we consider these to be INTERNAL ASSUMPIONS that should not be
exposed through this module's public Python interface.
"""
from __future__ import annotations
import logging
import typing as t
from dataclasses import dataclass, asdict
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import NotFound
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryUsageLocatorV2
from xblock.exceptions import XBlockNotFoundError
from xblock.fields import Scope, String, Integer
from xblock.core import XBlockMixin, XBlock
if t.TYPE_CHECKING:
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
logger = logging.getLogger(__name__)
class UpstreamLinkException(Exception):
"""
Raised whenever we try to inspect, sync-from, fetch-from, or delete a block's link to upstream content.
There are three flavors (defined below): BadDownstream, BadUpstream, NoUpstream.
Should be constructed with a human-friendly, localized, PII-free message, suitable for API responses and UI display.
For now, at least, the message can assume that upstreams are Content Library blocks and downstreams are Course
blocks, although that may need to change (see module docstring).
"""
class BadDownstream(UpstreamLinkException):
"""
Downstream content does not support sync.
"""
class BadUpstream(UpstreamLinkException):
"""
Reference to upstream content is malformed, invalid, and/or inaccessible.
"""
class NoUpstream(UpstreamLinkException):
"""
The downstream content does not have an upstream link at all (...as is the case for most XBlocks usages).
(This isn't so much an "error" like the other two-- it's just a case that needs to be handled exceptionally,
usually by logging a message and then doing nothing.)
"""
def __init__(self):
super().__init__(_("Content is not linked to a Content Library."))
@dataclass(frozen=True)
class UpstreamLink:
"""
Metadata about some downstream content's relationship with its linked upstream content.
"""
upstream_ref: str | None # Reference to the upstream content, e.g., a serialized library block usage key.
version_synced: int | None # Version of the upstream to which the downstream was last synced.
version_available: int | None # Latest version of the upstream that's available, or None if it couldn't be loaded.
version_declined: int | None # Latest version which the user has declined to sync with, if any.
error_message: str | None # If link is valid, None. Otherwise, a localized, human-friendly error message.
@property
def ready_to_sync(self) -> bool:
"""
Should we invite the downstream's authors to sync the latest upstream updates?
"""
return bool(
self.upstream_ref and
self.version_available and
self.version_available > (self.version_synced or 0) and
self.version_available > (self.version_declined or 0)
)
@property
def upstream_link(self) -> str | None:
"""
Link to edit/view upstream block in library.
"""
if self.version_available is None or self.upstream_ref is None:
return None
try:
usage_key = LibraryUsageLocatorV2.from_string(self.upstream_ref)
except InvalidKeyError:
return None
return _get_library_xblock_url(usage_key)
def to_json(self) -> dict[str, t.Any]:
"""
Get an JSON-API-friendly representation of this upstream link.
"""
return {
**asdict(self),
"ready_to_sync": self.ready_to_sync,
"upstream_link": self.upstream_link,
}
@classmethod
def try_get_for_block(cls, downstream: XBlock) -> t.Self:
"""
Same as `get_for_block`, but upon failure, sets `.error_message` instead of raising an exception.
"""
try:
return cls.get_for_block(downstream)
except UpstreamLinkException as exc:
logger.exception(
"Tried to inspect an unsupported, broken, or missing downstream->upstream link: '%s'->'%s'",
downstream.usage_key,
downstream.upstream,
)
return cls(
upstream_ref=downstream.upstream,
version_synced=downstream.upstream_version,
version_available=None,
version_declined=None,
error_message=str(exc),
)
@classmethod
def get_for_block(cls, downstream: XBlock) -> t.Self:
"""
Get info on a block's relationship with its linked upstream content (without actually loading the content).
Currently, the only supported upstreams are LC-backed Library Components. This may change in the future (see
module docstring).
If link exists, is supported, and is followable, returns UpstreamLink.
Otherwise, raises an UpstreamLinkException.
"""
if not downstream.upstream:
raise NoUpstream()
if not isinstance(downstream.usage_key.context_key, CourseKey):
raise BadDownstream(_("Cannot update content because it does not belong to a course."))
if downstream.has_children:
raise BadDownstream(_("Updating content with children is not yet supported."))
try:
upstream_key = LibraryUsageLocatorV2.from_string(downstream.upstream)
except InvalidKeyError as exc:
raise BadUpstream(_("Reference to linked library item is malformed")) from exc
downstream_type = downstream.usage_key.block_type
if upstream_key.block_type != downstream_type:
# Note: Currently, we strictly enforce that the downstream and upstream block_types must exactly match.
# It could be reasonable to relax this requirement in the future if there's product need for it.
# For example, there's no reason that a StaticTabBlock couldn't take updates from an HtmlBlock.
raise BadUpstream(
_("Content type mismatch: {downstream_type} cannot be linked to {upstream_type}.").format(
downstream_type=downstream_type, upstream_type=upstream_key.block_type
)
) from TypeError(
f"downstream block '{downstream.usage_key}' is linked to "
f"upstream block of different type '{upstream_key}'"
)
# We import this here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready.
from openedx.core.djangoapps.content_libraries.api import (
get_library_block # pylint: disable=wrong-import-order
)
try:
lib_meta = get_library_block(upstream_key)
except XBlockNotFoundError as exc:
raise BadUpstream(_("Linked library item was not found in the system")) from exc
return cls(
upstream_ref=downstream.upstream,
version_synced=downstream.upstream_version,
version_available=(lib_meta.published_version_num if lib_meta else None),
version_declined=downstream.upstream_version_declined,
error_message=None,
)
def sync_from_upstream(downstream: XBlock, user: User) -> None:
"""
Update `downstream` with content+settings from the latest available version of its linked upstream content.
Preserves overrides to customizable fields; overwrites overrides to other fields.
Does not save `downstream` to the store. That is left up to the caller.
If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
"""
link, upstream = _load_upstream_link_and_block(downstream, user)
_update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=False)
_update_non_customizable_fields(upstream=upstream, downstream=downstream)
_update_tags(upstream=upstream, downstream=downstream)
downstream.upstream_version = link.version_available
def fetch_customizable_fields(*, downstream: XBlock, user: User, upstream: XBlock | None = None) -> None:
"""
Fetch upstream-defined value of customizable fields and save them on the downstream.
If `upstream` is provided, use that block as the upstream.
Otherwise, load the block specified by `downstream.upstream`, which may raise an UpstreamLinkException.
"""
if not upstream:
_link, upstream = _load_upstream_link_and_block(downstream, user)
_update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=True)
def _load_upstream_link_and_block(downstream: XBlock, user: User) -> tuple[UpstreamLink, XBlock]:
"""
Load the upstream metadata and content for a downstream block.
Assumes that the upstream content is an XBlock in an LC-backed content libraries. This assumption may need to be
relaxed in the future (see module docstring).
If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
"""
link = UpstreamLink.get_for_block(downstream) # can raise UpstreamLinkException
# We import load_block here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready.
from openedx.core.djangoapps.xblock.api import load_block, CheckPerm, LatestVersion # pylint: disable=wrong-import-order
try:
lib_block: XBlock = load_block(
LibraryUsageLocatorV2.from_string(downstream.upstream),
user,
check_permission=CheckPerm.CAN_READ_AS_AUTHOR,
version=LatestVersion.PUBLISHED,
)
except (NotFound, PermissionDenied) as exc:
raise BadUpstream(_("Linked library item could not be loaded: {}").format(downstream.upstream)) from exc
return link, lib_block
def _update_customizable_fields(*, upstream: XBlock, downstream: XBlock, only_fetch: bool) -> None:
"""
For each customizable field:
* Save the upstream value to a hidden field on the downstream ("FETCH").
* If `not only_fetch`, and if the field *isn't* customized on the downstream, then:
* Update it the downstream field's value from the upstream field ("SYNC").
Concrete example: Imagine `lib_problem` is our upstream and `course_problem` is our downstream.
* Say that the customizable fields are [display_name, max_attempts].
* Set `course_problem.upstream_display_name = lib_problem.display_name` ("fetch").
* If `not only_fetch`, and `course_problem.display_name` wasn't customized, then:
* Set `course_problem.display_name = lib_problem.display_name` ("sync").
* Set `course_problem.upstream_max_attempts = lib_problem.max_attempts` ("fetch").
* If `not only_fetch`, and `course_problem.max_attempts` wasn't customized, then:
* Set `course_problem.max_attempts = lib_problem.max_attempts` ("sync").
"""
syncable_field_names = _get_synchronizable_fields(upstream, downstream)
for field_name, fetch_field_name in downstream.get_customizable_fields().items():
if field_name not in syncable_field_names:
continue
# FETCH the upstream's value and save it on the downstream (ie, `downstream.upstream_$FIELD`).
old_upstream_value = getattr(downstream, fetch_field_name)
new_upstream_value = getattr(upstream, field_name)
setattr(downstream, fetch_field_name, new_upstream_value)
if only_fetch:
continue
# Okay, now for the nuanced part...
# We need to update the downstream field *iff it has not been customized**.
# Determining whether a field has been customized will differ in Beta vs Future release.
# (See "PRESERVING DOWNSTREAM CUSTOMIZATIONS" comment below for details.)
## FUTURE BEHAVIOR: field is "customized" iff we have noticed that the user edited it.
# if field_name in downstream.downstream_customized:
# continue
## BETA BEHAVIOR: field is "customized" iff we have the prev upstream value, but field doesn't match it.
downstream_value = getattr(downstream, field_name)
if old_upstream_value and downstream_value != old_upstream_value:
continue # Field has been customized. Don't touch it. Move on.
# Field isn't customized -- SYNC it!
setattr(downstream, field_name, new_upstream_value)
def _update_non_customizable_fields(*, upstream: XBlock, downstream: XBlock) -> None:
"""
For each field `downstream.blah` that isn't customizable: set it to `upstream.blah`.
"""
syncable_fields = _get_synchronizable_fields(upstream, downstream)
customizable_fields = set(downstream.get_customizable_fields().keys())
for field_name in syncable_fields - customizable_fields:
new_upstream_value = getattr(upstream, field_name)
setattr(downstream, field_name, new_upstream_value)
def _update_tags(*, upstream: XBlock, downstream: XBlock) -> None:
"""
Update tags from `upstream` to `downstream`
"""
from openedx.core.djangoapps.content_tagging.api import copy_tags_as_read_only
# For any block synced with an upstream, copy the tags as read_only
# This keeps tags added locally.
copy_tags_as_read_only(
str(upstream.location),
str(downstream.location),
)
def _get_synchronizable_fields(upstream: XBlock, downstream: XBlock) -> set[str]:
"""
The syncable fields are the ones which are content- or settings-scoped AND are defined on both (up,down)stream.
"""
return set.intersection(*[
set(
field_name
for (field_name, field) in block.__class__.fields.items()
if field.scope in [Scope.settings, Scope.content]
)
for block in [upstream, downstream]
])
def decline_sync(downstream: XBlock) -> None:
"""
Given an XBlock that is linked to upstream content, mark the latest available update as 'declined' so that its
authors are not prompted (until another upstream version becomes available).
Does not save `downstream` to the store. That is left up to the caller.
If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
"""
upstream_link = UpstreamLink.get_for_block(downstream) # Can raise UpstreamLinkException
downstream.upstream_version_declined = upstream_link.version_available
def sever_upstream_link(downstream: XBlock) -> None:
"""
Given an XBlock that is linked to upstream content, disconnect the link, such that authors are never again prompted
to sync upstream updates. Erase all `.upstream*` fields from the downtream block.
However, before nulling out the `.upstream` field, we copy its value over to `.copied_from_block`. This makes sense,
because once a downstream block has been de-linked from source (e.g., a Content Library block), it is no different
than if the block had just been copy-pasted in the first place.
Does not save `downstream` to the store. That is left up to the caller.
If `downstream` lacks a link, then this raises NoUpstream (though it is reasonable for callers to handle such
exception and ignore it, as the end result is the same: `downstream.upstream is None`).
"""
if not downstream.upstream:
raise NoUpstream()
downstream.copied_from_block = downstream.upstream
downstream.upstream = None
downstream.upstream_version = None
for _, fetched_upstream_field in downstream.get_customizable_fields().items():
setattr(downstream, fetched_upstream_field, None) # Null out upstream_display_name, et al.
def _get_library_xblock_url(usage_key: LibraryUsageLocatorV2):
"""
Gets authoring url for given library_key.
"""
library_url = None
if mfe_base_url := settings.COURSE_AUTHORING_MICROFRONTEND_URL: # type: ignore
library_key = usage_key.lib_key
library_url = f'{mfe_base_url}/library/{library_key}/components?usageKey={usage_key}'
return library_url
class UpstreamSyncMixin(XBlockMixin):
"""
Allows an XBlock in the CMS to be associated & synced with an upstream.
Mixed into CMS's XBLOCK_MIXINS, but not LMS's.
"""
# Upstream synchronization metadata fields
upstream = String(
help=(
"The usage key of a block (generally within a content library) which serves as a source of upstream "
"updates for this block, or None if there is no such upstream. Please note: It is valid for this "
"field to hold a usage key for an upstream block that does not exist (or does not *yet* exist) on "
"this instance, particularly if this downstream block was imported from a different instance."
),
default=None, scope=Scope.settings, hidden=True, enforce_type=True
)
upstream_version = Integer(
help=(
"Record of the upstream block's version number at the time this block was created from it. If this "
"upstream_version is smaller than the upstream block's latest published version, then the author will be "
"invited to sync updates into this downstream block, presuming that they have not already declined to sync "
"said version."
),
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
)
upstream_version_declined = Integer(
help=(
"Record of the latest upstream version for which the author declined to sync updates, or None if they have "
"never declined an update."
),
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
)
# Store the fetched upstream values for customizable fields.
upstream_display_name = String(
help=("The value of display_name on the linked upstream block."),
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
)
upstream_max_attempts = Integer(
help=("The value of max_attempts on the linked upstream block."),
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
)
@classmethod
def get_customizable_fields(cls) -> dict[str, str]:
"""
Mapping from each customizable field to the field which can be used to restore its upstream value.
XBlocks outside of edx-platform can override this in order to set up their own customizable fields.
"""
return {
"display_name": "upstream_display_name",
"max_attempts": "upstream_max_attempts",
}
# PRESERVING DOWNSTREAM CUSTOMIZATIONS and RESTORING UPSTREAM VALUES
#
# For the full Content Libraries Relaunch, we would like to keep track of which customizable fields the user has
# actually customized. The idea is: once an author has customized a customizable field....
#
# - future upstream syncs will NOT blow away the customization,
# - but future upstream syncs WILL fetch the upstream values and tuck them away in a hidden field,
# - and the author can can revert back to said fetched upstream value at any point.
#
# Now, whether field is "customized" (and thus "revertible") is dependent on whether they have ever edited it.
# To instrument this, we need to keep track of which customizable fields have been edited using a new XBlock field:
# `downstream_customized`
#
# Implementing `downstream_customized` has proven difficult, because there is no simple way to keep it up-to-date
# with the many different ways XBlock fields can change. The `.save()` and `.editor_saved()` methods are promising,
# but we need to do more due diligence to be sure that they cover all cases, including API edits, import/export,
# copy/paste, etc. We will figure this out in time for the full Content Libraries Relaunch (related ticket:
# https://github.com/openedx/frontend-app-authoring/issues/1317). But, for the Beta realease, we're going to
# implement something simpler:
#
# - We fetch upstream values for customizable fields and tuck them away in a hidden field (same as above).
# - If a customizable field DOES match the fetched upstream value, then future upstream syncs DO update it.
# - If a customizable field does NOT the fetched upstream value, then future upstream syncs DO NOT update it.
# - There is no UI option for explicitly reverting back to the fetched upstream value.
#
# For future reference, here is a partial implementation of what we are thinking for the full Content Libraries
# Relaunch::
#
# downstream_customized = List(
# help=(
# "Names of the fields which have values set on the upstream block yet have been explicitly "
# "overridden on this downstream block. Unless explicitly cleared by the user, these customizations "
# "will persist even when updates are synced from the upstream."
# ),
# default=[], scope=Scope.settings, hidden=True, enforce_type=True,
# )
#
# def save(self, *args, **kwargs):
# """
# Update `downstream_customized` when a customizable field is modified.
#
# NOTE: This does not work, because save() isn't actually called in all the cases that we'd want it to be.
# """
# super().save(*args, **kwargs)
# customizable_fields = self.get_customizable_fields()
#
# # Loop through all the fields that are potentially cutomizable.
# for field_name, restore_field_name in self.get_customizable_fields():
#
# # If the field is already marked as customized, then move on so that we don't
# # unneccessarily query the block for its current value.
# if field_name in self.downstream_customized:
# continue
#
# # If this field's value doesn't match the synced upstream value, then mark the field
# # as customized so that we don't clobber it later when syncing.
# # NOTE: Need to consider the performance impact of all these field lookups.
# if getattr(self, field_name) != getattr(self, restore_field_name):
# self.downstream_customized.append(field_name)

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" focusable="false" aria-hidden="true">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25ZM21.41 6.34l-3.75-3.75-2.53 2.54 3.75 3.75 2.53-2.54Z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 292 B

View File

@@ -3,7 +3,7 @@ define([
], function($, CourseDetailsModel, MainView) {
'use strict';
return function(detailsUrl, showMinGradeWarning, showCertificateAvailableDate, upgradeDeadline, useV2CertDisplaySettings) {
return function(detailsUrl, showMinGradeWarning, showCertificateAvailableDate, upgradeDeadline) {
var model;
// highlighting labels when fields are focused in
$('form :input')
@@ -23,7 +23,6 @@ define([
model = new CourseDetailsModel();
model.urlRoot = detailsUrl;
model.showCertificateAvailableDate = showCertificateAvailableDate;
model.useV2CertDisplaySettings = useV2CertDisplaySettings;
model.set('upgrade_deadline', upgradeDeadline);
model.fetch({
// eslint-disable-next-line no-shadow
@@ -33,7 +32,6 @@ define([
model: model,
showMinGradeWarning: showMinGradeWarning
});
editor.useV2CertDisplaySettings = useV2CertDisplaySettings;
editor.render();
},
reset: true,

View File

@@ -1,83 +0,0 @@
define([ // jshint ignore:line
'jquery',
'underscore',
'gettext',
'common/js/components/utils/view_utils',
'edx-ui-toolkit/js/utils/string-utils',
'edx-ui-toolkit/js/utils/html-utils'
],
function($, _, gettext, ViewUtils, StringUtils, HtmlUtils) {
'use strict';
return function(maintenanceViewURL) {
var showError;
// Reset values
$('#reset-button').click(function(e) {
e.preventDefault();
$('#course-id').val('');
$('#dry-run').prop('checked', true);
// clear out result container
$('#result-container').html('');
});
showError = function(containerElSelector, error) {
var errorWrapperElSelector, errorHtml;
errorWrapperElSelector = containerElSelector + ' .wrapper-error';
errorHtml = HtmlUtils.joinHtml(
HtmlUtils.HTML('<div class="error" aria-live="polite" id="course-id-error">'),
error,
HtmlUtils.HTML('</div>')
);
HtmlUtils.setHtml($(errorWrapperElSelector), HtmlUtils.HTML(errorHtml));
$(errorWrapperElSelector).css('display', 'inline-block');
$(errorWrapperElSelector).fadeOut(5000);
};
$('form#force_publish').submit(function(event) {
var attrs, forcePublishedTemplate, $submitButton, deferred, promise, data;
event.preventDefault();
// clear out result container
$('#result-container').html('');
$submitButton = $('#submit_force_publish');
deferred = new $.Deferred();
promise = deferred.promise();
data = $('#force_publish').serialize();
// disable submit button while executing.
ViewUtils.disableElementWhileRunning($submitButton, function() { return promise; });
$.ajax({
type: 'POST',
url: maintenanceViewURL,
dataType: 'json',
data: data
})
.done(function(response) {
if (response.error) {
showError('#course-id-container', response.msg);
} else {
if (response.msg) {
showError('#result-error', response.msg);
} else {
attrs = $.extend({}, response, {StringUtils: StringUtils});
forcePublishedTemplate = HtmlUtils.template(
$('#force-published-course-response-tpl').text()
);
HtmlUtils.setHtml($('#result-container'), forcePublishedTemplate(attrs));
}
}
})
.fail(function() {
// response.responseText here because it would show some strange output, it may output Traceback
// sometimes if unexpected issue arises. Better to show just internal error when getting 500 error.
showError('#result-error', gettext('Internal Server Error.'));
})
.always(function() {
deferred.resolve();
});
});
};
});

View File

@@ -11,7 +11,8 @@ define(['backbone'], function(Backbone) {
// boilerplate_name (may be null)
// is_common (only used for problems)
templates: [],
support_legend: {}
support_legend: {},
beta: false,
},
parse: function(response) {
// Returns true only for templates that both have no boilerplate and are of
@@ -26,6 +27,7 @@ define(['backbone'], function(Backbone) {
this.templates = response.templates;
this.display_name = response.display_name;
this.support_legend = response.support_legend;
this.beta = response.beta;
// Sort the templates.
this.templates.sort(function(a, b) {

View File

@@ -84,35 +84,33 @@ function(Backbone, _, gettext, ValidationHelpers, DateUtils, StringUtils) {
);
}
if (this.useV2CertDisplaySettings) {
if (
newattrs.certificates_display_behavior
&& !(Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).includes(newattrs.certificates_display_behavior))
) {
errors.certificates_display_behavior = StringUtils.interpolate(
gettext(
'The certificate display behavior must be one of: {behavior_options}'
),
{
behavior_options: Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).join(', ')
}
);
}
if (
newattrs.certificates_display_behavior
&& !(Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).includes(newattrs.certificates_display_behavior))
) {
errors.certificates_display_behavior = StringUtils.interpolate(
gettext(
'The certificate display behavior must be one of: {behavior_options}'
),
{
behavior_options: Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).join(', ')
}
);
}
// Throw error if there's a value for certificate_available_date
if (
(newattrs.certificate_available_date && newattrs.certificates_display_behavior != CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
|| (!newattrs.certificate_available_date && newattrs.certificates_display_behavior == CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
) {
errors.certificates_display_behavior = StringUtils.interpolate(
gettext(
'The certificates display behavior must be {valid_option} if certificate available date is set.'
),
{
valid_option: CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE
}
);
}
// Throw error if there's a value for certificate_available_date
if (
(newattrs.certificate_available_date && newattrs.certificates_display_behavior != CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
|| (!newattrs.certificate_available_date && newattrs.certificates_display_behavior == CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
) {
errors.certificates_display_behavior = StringUtils.interpolate(
gettext(
'The certificates display behavior must be {valid_option} if certificate available date is set.'
),
{
valid_option: CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE
}
);
}
if (newattrs.intro_video && newattrs.intro_video !== this.get('intro_video')) {

View File

@@ -92,6 +92,7 @@ installEditTemplates = function(append) {
TemplateHelpers.installTemplate('edit-xblock-modal');
TemplateHelpers.installTemplate('editor-mode-button');
TemplateHelpers.installTemplate('edit-title-button');
TemplateHelpers.installTemplate('edit-upstream-alert');
// Add templates needed by the settings editor
TemplateHelpers.installTemplate('metadata-editor');

View File

@@ -0,0 +1,75 @@
/**
* Provides utilities to open and close the library content picker.
* This is for adding a single, selected, non-randomized component (XBlock)
* from the library into the course. It achieves the same effect as copy-pasting
* the block from a library into the course. The block will remain synced with
* the "upstream" library version.
*
* Compare cms/static/js/views/modals/select_v2_library_content.js which uses
* a multi-select modal to add component(s) to a Problem Bank (for
* randomization).
*/
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal'],
function($, _, gettext, BaseModal) {
'use strict';
var AddLibraryContent = BaseModal.extend({
options: $.extend({}, BaseModal.prototype.options, {
modalName: 'add-component-from-library',
modalSize: 'lg',
view: 'studio_view',
viewSpecificClasses: 'modal-add-component-picker confirm',
// Translators: "title" is the name of the current component being edited.
titleFormat: gettext('Add library content'),
addPrimaryActionButton: false,
}),
initialize: function() {
BaseModal.prototype.initialize.call(this);
// Add event listen to close picker when the iframe tells us to
const handleMessage = (event) => {
if (event.data?.type === 'pickerComponentSelected') {
var requestData = {
library_content_key: event.data.usageKey,
category: event.data.category,
}
this.refreshFunction(requestData);
this.hide();
}
};
this.messageListener = window.addEventListener("message", handleMessage);
this.cleanupListener = () => { window.removeEventListener("message", handleMessage) };
},
hide: function() {
BaseModal.prototype.hide.call(this);
this.cleanupListener();
},
/**
* Adds the action buttons to the modal.
*/
addActionButtons: function() {
this.addActionButton('cancel', gettext('Cancel'));
},
/**
* Show a component picker modal from library.
* @param contentPickerUrl Url for component picker
* @param refreshFunction A function to refresh the block after it has been updated
*/
showComponentPicker: function(contentPickerUrl, refreshFunction) {
this.contentPickerUrl = contentPickerUrl;
this.refreshFunction = refreshFunction;
this.render();
this.show();
},
getContentHtml: function() {
return `<iframe src="${this.contentPickerUrl}" onload="this.contentWindow.focus()" frameborder="0" style="width: 100%; height: 100%;"/>`;
},
});
return AddLibraryContent;
});

View File

@@ -3,8 +3,9 @@
*/
define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/components/utils/view_utils',
'js/views/components/add_xblock_button', 'js/views/components/add_xblock_menu',
'js/views/components/add_library_content',
'edx-ui-toolkit/js/utils/html-utils'],
function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, HtmlUtils) {
function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, AddLibraryContent, HtmlUtils) {
'use strict';
var AddXBlockComponent = BaseView.extend({
@@ -67,14 +68,32 @@ function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, Htm
oldOffset = ViewUtils.getScrollOffset(this.$el);
event.preventDefault();
this.closeNewComponent(event);
ViewUtils.runOperationShowingMessage(
gettext('Adding'),
_.bind(this.options.createComponent, this, saveData, $element)
).always(function() {
// Restore the scroll position of the buttons so that the new
// component appears above them.
ViewUtils.setScrollOffset(self.$el, oldOffset);
});
if (saveData.type === 'library_v2') {
var modal = new AddLibraryContent();
modal.showComponentPicker(
this.options.libraryContentPickerUrl,
function(data) {
ViewUtils.runOperationShowingMessage(
gettext('Adding'),
_.bind(this.options.createComponent, this, data, $element),
).always(function() {
// Restore the scroll position of the buttons so that the new
// component appears above them.
ViewUtils.setScrollOffset(self.$el, oldOffset);
});
}.bind(this)
);
} else {
ViewUtils.runOperationShowingMessage(
gettext('Adding'),
_.bind(this.options.createComponent, this, saveData, $element),
).always(function() {
// Restore the scroll position of the buttons so that the new
// component appears above them.
ViewUtils.setScrollOffset(self.$el, oldOffset);
});
}
}
});

View File

@@ -8,7 +8,8 @@ define(['js/views/baseview', 'edx-ui-toolkit/js/utils/html-utils'],
var attributes = {
type: this.model.type,
templates: this.model.templates,
display_name: this.model.display_name
display_name: this.model.display_name,
beta: this.model.beta,
};
BaseView.prototype.initialize.call(this);
this.template = this.loadTemplate('add-xblock-component-button');

View File

@@ -75,6 +75,27 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE
this.$('.modal-window-title').html(this.loadTemplate('edit-title-button')({title: title}));
},
createWarningToast: function(upstreamLink) {
// xss-lint: disable=javascript-jquery-insertion
this.$('.modal-header').before(this.loadTemplate('edit-upstream-alert')({
upstreamLink: upstreamLink,
}));
},
getXBlockUpstreamLink: function() {
const usageKey = this.xblockElement.data('locator');
$.ajax({
url: '/api/contentstore/v2/downstreams/' + usageKey,
type: 'GET',
success: function(data) {
if (data?.upstream_link) {
this.createWarningToast(data.upstream_link);
}
}.bind(this),
notifyOnError: false,
})
},
onDisplayXBlock: function() {
var editorView = this.editorView,
title = this.getTitle(),
@@ -101,6 +122,7 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE
} else {
this.$('.modal-window-title').text(title);
}
this.getXBlockUpstreamLink();
// If the xblock is not using custom buttons then choose which buttons to show
if (!editorView.hasCustomButtons()) {

View File

@@ -0,0 +1,117 @@
/**
* The PreviewLibraryChangesModal is a Backbone view that shows an iframe in a
* modal window. The iframe embeds a view from the Authoring MFE that allows
* authors to preview the new version of a library-sourced XBlock, and decide
* whether to accept ("sync") or reject ("ignore") the changes.
*/
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common/js/components/utils/view_utils'],
function($, _, gettext, BaseModal, ViewUtils) {
'use strict';
var PreviewLibraryChangesModal = BaseModal.extend({
events: _.extend({}, BaseModal.prototype.events, {
'click .action-accept': 'acceptChanges',
'click .action-ignore': 'ignoreChanges',
}),
options: $.extend({}, BaseModal.prototype.options, {
modalName: 'preview-lib-changes',
modalSize: 'lg',
view: 'studio_view',
viewSpecificClasses: 'modal-lib-preview confirm',
// Translators: "title" is the name of the current component being edited.
titleFormat: gettext('Preview changes to: {title}'),
addPrimaryActionButton: false,
}),
initialize: function() {
BaseModal.prototype.initialize.call(this);
},
/**
* Adds the action buttons to the modal.
*/
addActionButtons: function() {
this.addActionButton('accept', gettext('Accept changes'), true);
this.addActionButton('ignore', gettext('Ignore changes'));
this.addActionButton('cancel', gettext('Cancel'));
},
/**
* Show an edit modal for the specified xblock
* @param xblockInfo The XBlockInfo model that describes the xblock.
* @param courseAuthoringMfeUrl The course authoring mfe url.
* @param upstreamBlockId The library block id.
* @param upstreamBlockVersionSynced The library block current version.
* @param refreshFunction A function to refresh the block after it has been updated
*/
showPreviewFor: function(
xblockInfo,
courseAuthoringMfeUrl,
upstreamBlockId,
upstreamBlockVersionSynced,
refreshFunction
) {
this.xblockInfo = xblockInfo;
this.courseAuthoringMfeUrl = courseAuthoringMfeUrl;
this.downstreamBlockId = this.xblockInfo.get('id');
this.upstreamBlockId = upstreamBlockId;
this.upstreamBlockVersionSynced = upstreamBlockVersionSynced;
this.refreshFunction = refreshFunction;
this.render();
this.show();
},
getContentHtml: function() {
return `
<iframe src="${this.courseAuthoringMfeUrl}/legacy/preview-changes/${this.upstreamBlockId}?old=${this.upstreamBlockVersionSynced}">
`;
},
getTitle: function() {
var displayName = this.xblockInfo.get('display_name');
if (!displayName) {
if (this.xblockInfo.isVertical()) {
displayName = gettext('Unit');
} else {
displayName = gettext('Component');
}
}
return edx.StringUtils.interpolate(
this.options.titleFormat, {
title: displayName
}
);
},
acceptChanges: function(event) {
event.preventDefault();
$.post(`/api/contentstore/v2/downstreams/${this.downstreamBlockId}/sync`).done(() => {
this.hide();
this.refreshFunction();
}); // Note: if this POST request fails, Studio will display an error toast automatically.
},
ignoreChanges: function(event) {
event.preventDefault();
ViewUtils.confirmThenRunOperation(
gettext('Ignore these changes?'),
gettext('Would you like to permanently ignore this updated version? If so, you won\'t be able to update this until a newer version is published (in the library).'),
gettext('Ignore'),
() => {
$.ajax({
type: 'DELETE',
url: `/api/contentstore/v2/downstreams/${this.downstreamBlockId}/sync`,
data: {},
}).done(() => {
this.hide();
this.refreshFunction();
}); // Note: if this DELETE request fails, Studio will display an error toast automatically.
}
);
},
});
return PreviewLibraryChangesModal;
});

View File

@@ -0,0 +1,102 @@
/**
* Provides utilities to open and close the library content picker.
* This is for adding multiple components to a Problem Bank (for randomization).
*
* Compare cms/static/js/views/components/add_library_content.js which uses
* a single-select modal to add one component to a course (non-randomized).
*/
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal'],
function($, _, gettext, BaseModal) {
'use strict';
var SelectV2LibraryContent = BaseModal.extend({
options: $.extend({}, BaseModal.prototype.options, {
modalName: 'add-components-from-library',
modalSize: 'lg',
view: 'studio_view',
viewSpecificClasses: 'modal-add-component-picker confirm',
titleFormat: gettext('Add library content'),
addPrimaryActionButton: false,
}),
events: {
'click .action-add': 'addSelectedComponents',
'click .action-cancel': 'cancel',
},
initialize: function() {
BaseModal.prototype.initialize.call(this);
this.selections = [];
// Add event listen to close picker when the iframe tells us to
const handleMessage = (event) => {
if (event.data?.type === 'pickerSelectionChanged') {
this.selections = event.data.selections;
if (this.selections.length > 0) {
this.enableActionButton('add');
} else {
this.disableActionButton('add');
}
}
if (event.data?.type === 'addSelectedComponentsToBank') {
this.selections = event.data.payload.selectedComponents;
this.callback(this.selections);
}
};
this.messageListener = window.addEventListener("message", handleMessage);
this.cleanupListener = () => { window.removeEventListener("message", handleMessage) };
},
hide: function() {
BaseModal.prototype.hide.call(this);
this.cleanupListener();
},
/**
* Adds the action buttons to the modal.
*/
addActionButtons: function() {
this.addActionButton('add', gettext('Add selected components'), true);
this.addActionButton('cancel', gettext('Cancel'));
this.disableActionButton('add');
},
/** Handler when the user clicks the "Add Selected Components" primary button */
addSelectedComponents: function(event) {
if (event) {
event.preventDefault();
event.stopPropagation(); // Make sure parent modals don't see the click
}
this.hide();
this.callback(this.selections);
},
/**
* Show a component picker modal from library.
* @param contentPickerUrl Url for component picker
* @param callback A function to call with the selected block(s)
* @param isIframeEmbed Boolean indicating if the unit is displayed inside an iframe
*/
showComponentPicker: function(contentPickerUrl, callback, isIframeEmbed) {
this.contentPickerUrl = contentPickerUrl;
this.callback = callback;
if (isIframeEmbed) {
window.parent.postMessage(
{
type: 'showMultipleComponentPicker',
payload: {}
}, document.referrer
);
return true;
}
this.render();
this.show();
},
getContentHtml: function() {
return `<iframe src="${this.contentPickerUrl}" onload="this.contentWindow.focus()" frameborder="0" style="width: 100%; height: 100%;"/>`;
},
});
return SelectV2LibraryContent;
});

View File

@@ -8,14 +8,16 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page
'js/models/xblock_info', 'js/views/xblock_string_field_editor', 'js/views/xblock_access_editor',
'js/views/pages/container_subviews', 'js/views/unit_outline', 'js/views/utils/xblock_utils',
'common/js/components/views/feedback_notification', 'common/js/components/views/feedback_prompt',
'js/views/utils/tagging_drawer_utils', 'js/utils/module',
'js/views/utils/tagging_drawer_utils', 'js/utils/module', 'js/views/modals/preview_v2_library_changes',
'js/views/modals/select_v2_library_content'
],
function($, _, Backbone, gettext, BasePage,
ViewUtils, ContainerView, XBlockView,
AddXBlockComponent, EditXBlockModal, MoveXBlockModal,
XBlockInfo, XBlockStringFieldEditor, XBlockAccessEditor,
ContainerSubviews, UnitOutlineView, XBlockUtils,
NotificationView, PromptView, TaggingDrawerUtils, ModuleUtils) {
NotificationView, PromptView, TaggingDrawerUtils, ModuleUtils,
PreviewLibraryChangesModal, SelectV2LibraryContent) {
'use strict';
var XBlockContainerPage = BasePage.extend({
@@ -28,6 +30,8 @@ function($, _, Backbone, gettext, BasePage,
'click .copy-button': 'copyXBlock',
'click .move-button': 'showMoveXBlockModal',
'click .delete-button': 'deleteXBlock',
'click .library-sync-button': 'showXBlockLibraryChangesPreview',
'click .problem-bank-v2-add-button': 'showSelectV2LibraryContent',
'click .show-actions-menu-button': 'showXBlockActionsMenu',
'click .new-component-button': 'scrollToNewComponentButtons',
'click .save-button': 'saveSelectedLibraryComponents',
@@ -124,6 +128,14 @@ function($, _, Backbone, gettext, BasePage,
}
if (this.options.isIframeEmbed) {
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'refreshXBlock') {
this.render();
}
});
}
this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved);
},
@@ -207,12 +219,13 @@ function($, _, Backbone, gettext, BasePage,
renderAddXBlockComponents: function() {
var self = this;
if (self.options.canEdit) {
if (self.options.canEdit && !self.options.isIframeEmbed) {
this.$('.add-xblock-component').each(function(index, element) {
var component = new AddXBlockComponent({
el: element,
createComponent: _.bind(self.createComponent, self),
collection: self.options.templates
collection: self.options.templates,
libraryContentPickerUrl: self.options.libraryContentPickerUrl,
});
component.render();
});
@@ -222,7 +235,7 @@ function($, _, Backbone, gettext, BasePage,
},
initializePasteButton() {
if (this.options.canEdit) {
if (this.options.canEdit && !this.options.isIframeEmbed) {
// We should have the user's clipboard status.
const data = this.options.clipboardData;
this.refreshPasteButton(data);
@@ -239,7 +252,7 @@ function($, _, Backbone, gettext, BasePage,
refreshPasteButton(data) {
// Do not perform any changes on paste button since they are not
// rendered on Library or LibraryContent pages
if (!this.isLibraryPage && !this.isLibraryContentPage) {
if (!this.isLibraryPage && !this.isLibraryContentPage && !this.options.isIframeEmbed) {
// 'data' is the same data returned by the "get clipboard status" API endpoint
// i.e. /api/content-staging/v1/clipboard/
if (this.options.canEdit && data.content) {
@@ -252,6 +265,7 @@ function($, _, Backbone, gettext, BasePage,
} else {
// The thing in the clipboard can be pasted into this unit:
const detailsPopupEl = this.$(".clipboard-details-popup")[0];
if (!detailsPopupEl) return; // This happens on the Problem Bank container page - no paste button is there anyways
detailsPopupEl.querySelector(".detail-block-name").innerText = data.content.display_name;
detailsPopupEl.querySelector(".detail-block-type").innerText = data.content.block_type_display;
detailsPopupEl.querySelector(".detail-course-name").innerText = data.source_context_title;
@@ -273,6 +287,18 @@ function($, _, Backbone, gettext, BasePage,
/** The user has clicked on the "Paste Component button" */
pasteComponent(event) {
event.preventDefault();
try {
if (this.options.isIframeEmbed) {
window.parent.postMessage(
{
type: 'pasteComponent',
payload: {}
}, document.referrer
);
}
} catch (e) {
console.error(e);
}
// Get the ID of the container (usually a unit/vertical) that we're pasting into:
const parentElement = this.findXBlockElement(event.target);
const parentLocator = parentElement.data('locator');
@@ -365,6 +391,18 @@ function($, _, Backbone, gettext, BasePage,
editXBlock: function(event, options) {
event.preventDefault();
try {
if (this.options.isIframeEmbed) {
window.parent.postMessage(
{
type: 'editXBlock',
payload: {}
}, document.referrer
);
}
} catch (e) {
console.error(e);
}
if (!options || options.view !== 'visibility_view') {
const primaryHeader = $(event.target).closest('.xblock-header-primary, .nav-actions');
@@ -378,7 +416,13 @@ function($, _, Backbone, gettext, BasePage,
|| (useNewVideoEditor === 'True' && blockType === 'video')
|| (useNewProblemEditor === 'True' && blockType === 'problem')
) {
var destinationUrl = primaryHeader.attr('authoring_MFE_base_url') + '/' + blockType + '/' + encodeURI(primaryHeader.attr('data-usage-id'));
var destinationUrl = primaryHeader.attr('authoring_MFE_base_url')
+ '/' + blockType
+ '/' + encodeURI(primaryHeader.attr('data-usage-id'));
var upstreamRef = primaryHeader.attr('data-upstream-ref');
if(upstreamRef) {
destinationUrl += '?upstreamLibRef=' + upstreamRef;
}
window.location.href = destinationUrl;
return;
}
@@ -396,6 +440,93 @@ function($, _, Backbone, gettext, BasePage,
});
},
/** Show the modal for previewing changes before syncing a library-sourced XBlock. */
showXBlockLibraryChangesPreview: function(event, options) {
const xblockElement = this.findXBlockElement(event.target);
const self = this;
const xblockInfo = XBlockUtils.findXBlockInfo(xblockElement, this.model);
const courseAuthoringMfeUrl = this.model.attributes.course_authoring_url;
const headerElement = xblockElement.find('.xblock-header-primary');
const upstreamBlockId = headerElement.data('upstream-ref');
const upstreamBlockVersionSynced = headerElement.data('version-synced');
try {
if (this.options.isIframeEmbed) {
window.parent.postMessage(
{
type: 'showXBlockLibraryChangesPreview',
payload: {
downstreamBlockId: xblockInfo.get('id'),
displayName: xblockInfo.get('display_name'),
isVertical: xblockInfo.isVertical(),
upstreamBlockId,
upstreamBlockVersionSynced,
}
}, document.referrer
);
return true;
}
} catch (e) {
console.error(e);
}
event.preventDefault();
var modal = new PreviewLibraryChangesModal(options);
modal.showPreviewFor(
xblockInfo,
courseAuthoringMfeUrl,
upstreamBlockId,
upstreamBlockVersionSynced,
function() { self.refreshXBlock(xblockElement, false); }
);
},
/** Show the multi-select library content picker, for adding to a Problem Bank (itembank) Component */
showSelectV2LibraryContent: function(event, options) {
event.preventDefault();
const xblockElement = this.findXBlockElement(event.target);
const modal = new SelectV2LibraryContent(options);
const courseAuthoringMfeUrl = this.model.attributes.course_authoring_url;
const itemBankBlockId = xblockElement.data("locator");
const pickerUrl = courseAuthoringMfeUrl + '/component-picker/multiple?variant=published';
modal.showComponentPicker(pickerUrl, (selectedBlocks) => {
// selectedBlocks has type: {usageKey: string, blockType: string}[]
let doneAddingAllBlocks = () => { this.refreshXBlock(xblockElement, false); };
let doneAddingBlock = () => {};
if (this.model.id === itemBankBlockId) {
// We're on the detailed view, showing all the components inside the problem bank.
// Create a placeholder that will become the new block(s)
const $insertSpot = xblockElement.find('.insert-new-lib-blocks-here');
doneAddingBlock = (addResult) => {
const $placeholderEl = $(this.createPlaceholderElement());
const placeholderElement = $placeholderEl.insertBefore($insertSpot);
placeholderElement.data('locator', addResult.locator);
return this.refreshXBlock(placeholderElement, true);
};
doneAddingAllBlocks = () => {};
}
// Note: adding all the XBlocks in parallel will cause a race condition 😢 so we have to add
// them one at a time:
let lastAdded = $.when();
for (const { usageKey, blockType } of selectedBlocks) {
const addData = {
library_content_key: usageKey,
category: blockType,
parent_locator: itemBankBlockId,
};
lastAdded = lastAdded.then(() => (
$.postJSON(this.getURLRoot() + '/', addData, doneAddingBlock)
));
}
// Now we actually add the block:
ViewUtils.runOperationShowingMessage(gettext('Adding'), () => {
return lastAdded.done(() => { doneAddingAllBlocks() });
});
}, this.options.isIframeEmbed);
},
/**
* If the new "Actions" menu is enabled, most XBlock actions like
* Duplicate, Move, Delete, Manage Access, etc. are moved into this
@@ -432,66 +563,43 @@ function($, _, Backbone, gettext, BasePage,
});
},
duplicateXBlock: function(event) {
event.preventDefault();
this.duplicateComponent(this.findXBlockElement(event.target));
},
openManageTags: function(event) {
try {
if (this.options.isIframeEmbed) {
window.parent.postMessage(
{
type: 'openManageTags',
payload: {}
}, document.referrer
);
}
} catch (e) {
console.error(e);
}
const taxonomyTagsWidgetUrl = this.model.get('taxonomy_tags_widget_url');
const contentId = this.findXBlockElement(event.target).data('locator');
TaggingDrawerUtils.openDrawer(taxonomyTagsWidgetUrl, contentId);
},
showMoveXBlockModal: function(event) {
var xblockElement = this.findXBlockElement(event.target),
parentXBlockElement = xblockElement.parents('.studio-xblock-wrapper'),
modal = new MoveXBlockModal({
sourceXBlockInfo: XBlockUtils.findXBlockInfo(xblockElement, this.model),
sourceParentXBlockInfo: XBlockUtils.findXBlockInfo(parentXBlockElement, this.model),
XBlockURLRoot: this.getURLRoot(),
outlineURL: this.options.outlineURL
});
event.preventDefault();
modal.show();
},
deleteXBlock: function(event) {
event.preventDefault();
this.deleteComponent(this.findXBlockElement(event.target));
},
createPlaceholderElement: function() {
return $('<div/>', {class: 'studio-xblock-wrapper'});
},
createComponent: function(template, target) {
// A placeholder element is created in the correct location for the new xblock
// and then onNewXBlock will replace it with a rendering of the xblock. Note that
// for xblocks that can't be replaced inline, the entire parent will be refreshed.
var parentElement = this.findXBlockElement(target),
parentLocator = parentElement.data('locator'),
buttonPanel = target.closest('.add-xblock-component'),
listPanel = buttonPanel.prev(),
scrollOffset = ViewUtils.getScrollOffset(buttonPanel),
$placeholderEl = $(this.createPlaceholderElement()),
requestData = _.extend(template, {
parent_locator: parentLocator
}),
placeholderElement;
placeholderElement = $placeholderEl.appendTo(listPanel);
return $.postJSON(this.getURLRoot() + '/', requestData,
_.bind(this.onNewXBlock, this, placeholderElement, scrollOffset, false))
.fail(function() {
// Remove the placeholder if the update failed
placeholderElement.remove();
});
},
copyXBlock: function(event) {
event.preventDefault();
try {
if (this.options.isIframeEmbed) {
window.parent.postMessage(
{
type: 'copyXBlock',
payload: {}
}, document.referrer
);
}
} catch (e) {
console.error(e);
}
const clipboardEndpoint = "/api/content-staging/v1/clipboard/";
const element = this.findXBlockElement(event.target);
const usageKeyToCopy = element.data('locator');
@@ -535,41 +643,58 @@ function($, _, Backbone, gettext, BasePage,
});
},
duplicateComponent: function(xblockElement) {
// A placeholder element is created in the correct location for the duplicate xblock
// and then onNewXBlock will replace it with a rendering of the xblock. Note that
// for xblocks that can't be replaced inline, the entire parent will be refreshed.
var self = this,
parentElement = self.findXBlockElement(xblockElement.parent()),
scrollOffset = ViewUtils.getScrollOffset(xblockElement),
$placeholderEl = $(self.createPlaceholderElement()),
placeholderElement;
placeholderElement = $placeholderEl.insertAfter(xblockElement);
XBlockUtils.duplicateXBlock(xblockElement, parentElement)
.done(function(data) {
self.onNewXBlock(placeholderElement, scrollOffset, true, data);
})
.fail(function() {
// Remove the placeholder if the update failed
placeholderElement.remove();
});
},
duplicateXBlock: function(event) {
event.preventDefault();
try {
if (this.options.isIframeEmbed) {
window.parent.postMessage(
{
type: 'duplicateXBlock',
payload: {}
}, document.referrer
);
}
} catch (e) {
console.error(e);
}
this.duplicateComponent(this.findXBlockElement(event.target));
},
showMoveXBlockModal: function(event) {
var xblockElement = this.findXBlockElement(event.target),
parentXBlockElement = xblockElement.parents('.studio-xblock-wrapper'),
sourceXBlockInfo = XBlockUtils.findXBlockInfo(xblockElement, this.model),
sourceParentXBlockInfo = XBlockUtils.findXBlockInfo(parentXBlockElement, this.model),
modal = new MoveXBlockModal({
sourceXBlockInfo: XBlockUtils.findXBlockInfo(xblockElement, this.model),
sourceParentXBlockInfo: XBlockUtils.findXBlockInfo(parentXBlockElement, this.model),
XBlockURLRoot: this.getURLRoot(),
outlineURL: this.options.outlineURL
});
sourceXBlockInfo: sourceXBlockInfo,
sourceParentXBlockInfo: sourceParentXBlockInfo,
XBlockURLRoot: this.getURLRoot(),
outlineURL: this.options.outlineURL
});
try {
if (this.options.isIframeEmbed) {
window.parent.postMessage(
{
type: 'showMoveXBlockModal',
payload: {
sourceXBlockInfo: {
id: sourceXBlockInfo.attributes.id,
displayName: sourceXBlockInfo.attributes.display_name,
},
sourceParentXBlockInfo: {
id: sourceParentXBlockInfo.attributes.id,
category: sourceParentXBlockInfo.attributes.category,
hasChildren: sourceParentXBlockInfo.attributes.has_children,
},
},
}, document.referrer
);
return true;
}
} catch (e) {
console.error(e);
}
event.preventDefault();
modal.show();
@@ -577,6 +702,18 @@ function($, _, Backbone, gettext, BasePage,
deleteXBlock: function(event) {
event.preventDefault();
try {
if (this.options.isIframeEmbed) {
window.parent.postMessage(
{
type: 'deleteXBlock',
payload: {}
}, document.referrer
);
}
} catch (e) {
console.error(e);
}
this.deleteComponent(this.findXBlockElement(event.target));
},
@@ -725,9 +862,10 @@ function($, _, Backbone, gettext, BasePage,
var matchBlockTypeFromLocator = /\@(.*?)\+/;
var blockType = data.locator.match(matchBlockTypeFromLocator);
}
if((useNewTextEditor === 'True' && blockType.includes('html'))
// open mfe editors for new blocks only and not for content imported from libraries
if(!data.hasOwnProperty('upstreamRef') && ((useNewTextEditor === 'True' && blockType.includes('html'))
|| (useNewVideoEditor === 'True' && blockType.includes('video'))
|| (useNewProblemEditor === 'True' && blockType.includes('problem'))
|| (useNewProblemEditor === 'True' && blockType.includes('problem')))
){
var destinationUrl;
if (useVideoGalleryFlow === "True" && blockType.includes("video")) {

View File

@@ -506,9 +506,13 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H
},
renderTagElements: function(tags, depth, parentId) {
/* This function displays the tags in the sidebar of the legacy Unit Outline Page.
* It is not used when the Authoring MFE iframes a component in the Unit Outline. */
const parentElement = document.querySelector(`.content-tags-${parentId}`);
if (!parentElement) return;
const tagListElement = this;
tags.forEach(function(tag) {
const parentElement = document.querySelector(`.content-tags-${parentId}`);
var tagContentElement = document.createElement('div'),
tagValueElement = document.createElement('span');

View File

@@ -388,9 +388,6 @@ function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel,
Hides and clears the certificate available date field if a display behavior that doesn't use it is
chosen. Because we are clearing it, toggling back to "end_with_date" will require re-entering the date
*/
if (!this.useV2CertDisplaySettings) {
return;
}
// eslint-disable-next-line prefer-const
let showDatepicker = this.model.get('certificates_display_behavior') == 'end_with_date';
// eslint-disable-next-line prefer-const

View File

@@ -77,7 +77,6 @@
@import 'views/group-configuration';
@import 'views/video-upload';
@import 'views/certificates';
@import 'views/maintenance';
// +Base - Contexts
// ====================

View File

@@ -66,3 +66,17 @@
height: ($baseline*3);
background: url('#{$static-path}/images/large-library-icon.png') center no-repeat;
}
.large-library_v2-icon {
display: inline-block;
width: ($baseline*3);
height: ($baseline*3);
background: url('#{$static-path}/images/large-library_v2-icon.png') center no-repeat;
}
.large-itembank-icon {
display: inline-block;
width: ($baseline*3);
height: ($baseline*3);
background: url('#{$static-path}/images/large-itembank-icon.png') center no-repeat;
}

View File

@@ -0,0 +1,658 @@
@import 'cms/theme/variables-v1';
@import 'elements/course-unit-mfe-iframe';
.wrapper {
.wrapper-xblock {
background-color: $transparent;
border-radius: 6px;
border: none;
&:hover {
border-color: none;
}
.xblock-header-primary {
padding: ($baseline * 1.2) ($baseline * 1.2) ($baseline / 1.67);
border-bottom: none;
.header-details .xblock-display-name {
font-size: 22px;
line-height: 28px;
font-weight: 700;
color: $black;
}
}
&.level-element {
box-shadow: 0 2px 4px rgba(0, 0, 0, .15), 0 2px 8px rgba(0, 0, 0, .15);
margin: 0 0 ($baseline * 1.4) 0;
}
&.level-element .xblock-header-primary {
background-color: $white;
}
&.level-element .xblock-render {
background: $white;
margin: 0;
padding: $baseline;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
.wrapper-xblock .header-actions .actions-list .action-item .action-button {
@extend %button-styles;
color: $primary;
.fa-ellipsis-v {
font-size: $base-font-size;
}
&:hover {
background-color: $primary;
color: $white;
border-color: $transparent;
}
&:focus {
outline: 2px $transparent;
background-color: $transparent;
box-shadow: inset 0 0 0 2px $primary;
color: $primary;
border-color: $transparent;
}
}
.xblock-header-primary .header-actions .nav-dd .nav-sub {
border: 1px solid rgba(0, 0, 0, .15);
border-radius: 6px;
padding: ($baseline / 2) 0;
min-width: 288px;
right: 90px;
.nav-item {
border-bottom: none;
color: $dark;
font-size: $base-font-size;
a {
padding: ($baseline / 2) ($baseline / 1.25);
}
&:hover {
background-color: #f2f0ef;
color: $dark;
}
a:hover {
color: $dark;
}
a:focus {
outline: none;
}
&:active {
background-color: $primary;
a {
color: $white;
}
}
&:last-child {
margin-bottom: 0;
}
}
}
}
&.wrapper-modal-window .modal-window .modal-actions a {
color: $text-color;
background-color: $transparent;
border-color: $transparent;
box-shadow: none;
font-weight: 500;
border: 1px solid $transparent;
padding: ($baseline / 2) ($baseline / 1.25);
font-size: $base-font-size;
line-height: 20px;
border-radius: $btn-border-radius;
text-align: center;
vertical-align: middle;
user-select: none;
background-image: none;
display: block;
&:hover {
background-color: $light-background-color;
border-color: $transparent;
color: $text-color;
}
&:focus {
background: $transparent;
outline: none;
}
}
.modal-window.modal-editor {
background-color: $white;
border-radius: 6px;
a {
color: #00688d;
}
select {
&:focus {
background-color: $white;
color: $text-color;
outline: 0;
box-shadow: 0 0 0 1px $primary;
}
}
input:not([type="radio"], [type="checkbox"], [type="submit"]) {
@extend %input-styles;
line-height: 20px;
height: 44px;
&:hover {
border: solid 1px #002121;
}
&:focus {
color: $text-color;
background-color: $white;
border-color: $primary;
outline: 0;
box-shadow: 0 0 0 1px $primary;
}
}
#student_training_settings_editor .openassessment_training_example .openassessment_training_example_body
.openassessment_training_example_essay_wrapper textarea {
@extend %input-styles;
box-shadow: none;
}
textarea {
@extend %input-styles;
box-shadow: none;
&#poll-question-editor,
&#poll-feedback-editor {
box-shadow: none;
&:focus {
color: $text-color;
background-color: $white;
border-color: $primary;
outline: 0;
box-shadow: 0 0 0 1px $primary;
}
}
&:hover {
border: solid 1px #002121;
}
&:focus {
color: $text-color;
background-color: $white;
border-color: $primary;
outline: 0;
box-shadow: 0 0 0 1px $primary;
}
}
.tip.setting-help {
color: $border-color;
font-size: 14px;
line-height: $base-font-size;
}
label,
.label.setting-label {
font-size: $base-font-size;
line-height: 28px;
color: $text-color;
font-weight: 400;
}
.title.modal-window-title {
color: $black;
font-weight: 700;
font-size: 22px;
line-height: 28px;
}
.xblock-actions {
background-color: $white;
.action-button {
background-color: $transparent;
border: 1px solid $transparent;
padding: ($baseline / 2.22) ($baseline / 1.25);
border-radius: $input-border-radius;
font-weight: 400;
color: #00688d;
font-size: $base-font-size;
line-height: 20px;
cursor: pointer;
&:focus {
outline: none;
}
}
.openassessment_save_button,
.save-button,
.continue-button {
color: $white;
background-color: $primary;
border-color: $primary;
box-shadow: none;
font-weight: 500;
border: 1px solid $transparent;
padding: ($baseline / 2) ($baseline / 1.25);
font-size: $base-font-size;
line-height: 20px;
border-radius: $btn-border-radius;
text-align: center;
vertical-align: middle;
user-select: none;
background-image: none;
height: auto;
display: block;
&:hover {
background: darken($primary, 5%);
border-color: $transparent;
color: $white;
}
&:focus {
background: $primary;
outline: none;
}
}
.openassessment_cancel_button,
.cancel-button {
color: $text-color;
background-color: $transparent;
border-color: $transparent;
box-shadow: none;
font-weight: 500;
border: 1px solid $transparent;
padding: ($baseline / 2) ($baseline / 1.25);
font-size: $base-font-size;
line-height: 20px;
border-radius: $btn-border-radius;
text-align: center;
vertical-align: middle;
user-select: none;
background-image: none;
display: block;
&:hover {
background-color: $light-background-color;
border-color: $transparent;
color: $text-color;
}
&:focus {
background: $transparent;
outline: none;
}
}
}
}
.modal-lg.modal-window.confirm.openassessment_modal_window {
height: 635px;
}
}
.view-container .content-primary {
background-color: $transparent;
width: 100%;
}
.wrapper-content.wrapper {
padding: $baseline / 4;
}
.btn-default.action-edit.title-edit-button {
@extend %button-styles;
position: relative;
top: 7px;
.fa-pencil {
display: none;
}
&::before {
@extend %icon-position;
content: '';
position: absolute;
background-color: $black;
mask: url('#{$static-path}/images/pencil-icon.svg') center no-repeat;
}
&:hover {
background-color: $primary;
border-color: $transparent;
&::before {
background-color: $white;
}
}
&:focus {
outline: 2px $transparent;
background-color: $transparent;
box-shadow: inset 0 0 0 2px $primary;
border-color: $transparent;
&:hover {
color: $white;
border-color: $transparent;
&::before {
background-color: $primary;
}
}
}
}
.library-sync-button {
.action-button-text {
display: none;
}
}
.action-edit {
.action-button-text {
display: none;
}
.edit-button.action-button {
@extend %button-styles;
position: relative;
.fa-pencil {
display: none;
}
&::before {
@extend %icon-position;
content: '';
position: absolute;
background-color: $black;
mask: url('#{$static-path}/images/pencil-icon.svg') center no-repeat;
}
&:hover {
background-color: $primary;
border-color: $transparent;
&::before {
background-color: $white;
}
}
&:focus {
outline: 2px $transparent;
background-color: $transparent;
box-shadow: inset 0 0 0 2px $primary;
border-color: $transparent;
&:hover {
color: $white;
border-color: $transparent;
&::before {
background-color: $primary;
}
}
}
}
}
.nav-dd.ui-right .nav-sub {
&::before,
&::after {
display: none;
}
}
[class*="view-"] .modal-lg.modal-editor .modal-header .editor-modes .action-item {
.editor-button,
.settings-button {
@extend %light-button;
}
}
[class*="view-"] .wrapper.wrapper-modal-window .modal-window .modal-actions .action-primary {
@extend %primary-button;
}
.wrapper-comp-settings {
.list-input.settings-list {
.metadata-list-enum .create-setting {
@extend %modal-actions-button;
background-color: $primary;
color: $white;
border: 1px solid $primary;
cursor: pointer;
&:hover {
background: darken($primary, 5%);
border-color: #2d494e;
}
&:focus {
background: $primary;
outline: none;
}
}
}
.list-input.settings-list {
.field.comp-setting-entry.is-set .setting-input {
color: $text-color;
}
select {
border: 1px solid $border-color;
border-radius: $input-border-radius;
color: $text-color;
height: 44px;
&:focus {
background-color: $white;
color: $text-color;
outline: 0;
box-shadow: 0 0 0 1px $primary;
}
}
.setting-label {
font-size: $base-font-size;
line-height: 28px;
color: $text-color;
font-weight: 400;
}
input[type="number"] {
width: 45%;
}
.action.setting-clear {
@extend %button-styles;
background-color: $transparent;
color: $primary;
border: none;
&:hover {
background-color: $primary;
color: $white;
border: none;
}
&:focus {
outline: 2px $transparent;
background-color: $transparent;
box-shadow: inset 0 0 0 2px $primary;
color: $primary;
}
}
}
.list-input.settings-list .metadata-list-enum .remove-setting .fa-times-circle {
color: $primary;
}
}
select {
border: 1px solid $border-color;
border-radius: $input-border-radius;
color: $text-color;
height: 44px;
padding: ($baseline / 2) ($baseline / 1.25);
&:focus {
background-color: $white;
color: $text-color;
outline: 0;
box-shadow: 0 0 0 1px $primary;
}
}
.xblock {
.xblock--drag-and-drop--editor .tab {
background-color: $white;
display: inline-block;
}
#openassessment-editor #oa_rubric_editor_wrapper {
.openassessment_criterion .openassessment_criterion_basic_editor .comp-setting-entry .wrapper-comp-settings label input,
.openassessment_criterion_option .openassessment_criterion_option_name_wrapper label,
.openassessment_criterion_option .openassessment_criterion_option_explanation_wrapper label,
.openassessment_criterion .openassessment_criterion_feedback_wrapper label,
#openassessment_rubric_feedback_wrapper label,
.openassessment_criterion_option .openassessment_criterion_option_point_wrapper label,
.openassessment_criterion_option .openassessment_criterion_option_name_wrapper label input,
.openassessment_criterion .openassessment_criterion_basic_editor .comp-setting-entry .wrapper-comp-settings .openassessment_criterion_prompt,
#openassessment_rubric_feedback_wrapper textarea,
.openassessment_criterion_option
.openassessment_criterion_option_explanation_wrapper label textarea,
input[type=number] {
font-size: $base-font-size;
background-color: $white;
background-image: none;
}
}
#openassessment-editor {
#openassessment_editor_header .editor_tabs .oa_editor_tab {
@extend %light-button;
padding: 0 10px;
}
#openassessment_editor_header,
.openassessment_tab_instructions {
background-color: $white;
}
#oa_schedule_editor_wrapper #dates_config_new_badge {
background-color: $primary;
}
.openassessment_description {
font-size: 14px;
line-height: $base-font-size;
}
#oa_rubric_editor_wrapper .openassessment_criterion
.openassessment_criterion_basic_editor .comp-setting-entry
.wrapper-comp-settings label {
font-size: $base-font-size;
line-height: 28px;
color: $text-color;
font-weight: 400;
}
#oa_rubric_editor_wrapper .openassessment_criterion_option
.openassessment_criterion_option_point_wrapper label input {
min-width: 70px;
font-size: 18px;
height: 44px;
}
.openassessment_assessment_module_settings_editor:hover {
border-color: $primary;
.drag-handle {
background-color: $primary;
border-color: $primary;
}
}
.setting-help,
.ti.wrapper-comp-settings .list-input.settings-list .setting-help {
color: $border-color;
font-size: 14px;
line-height: $base-font-size;
}
}
.xblock--drag-and-drop--editor .btn,
#openassessment-editor .openassessment_container_add_button,
#openassessment-editor #oa_rubric_editor_wrapper .openassessment_criterion .openassessment_criterion_add_option,
#student_training_settings_editor .openassessment_add_training_example {
@extend %primary-button;
}
}
.xblock-header-primary {
position: relative;
&::before {
content: '';
position: absolute;
top: 83px;
left: 25px;
width: 95%;
height: 1px;
background-color: #eae6e5;
}
}
.ui-loading {
background-color: #f8f7f6;
box-shadow: none;
.spin,
.copy {
color: $primary;
}
}
.wrapper-comp-setting.metadata-list-enum .action.setting-clear.active {
margin-top: 0;
}

View File

@@ -0,0 +1,65 @@
%button-styles {
width: 44px;
height: 44px;
border-radius: 50%;
}
%input-styles {
font-size: $base-font-size;
color: $text-color !important;
background-color: $white;
border: 1px solid $border-color !important;
border-radius: $input-border-radius !important;
padding: 10px 16px !important;
background-image: none;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
%modal-actions-button {
font-size: $base-font-size;
line-height: 20px;
padding: 10px 16px;
border-radius: $btn-border-radius;
}
%primary-button {
@extend %modal-actions-button;
background-color: $primary;
color: $white;
border: 1px solid $primary;
cursor: pointer;
background-image: none;
display: block;
&:hover {
background: darken($primary, 5%);
border-color: #2d494e;
box-shadow: none;
color: $white;
}
&:focus {
background: $primary;
outline: none;
box-shadow: none;
}
}
%light-button {
@extend %modal-actions-button;
color: $black;
background-color: $light-background-color;
border-color: $light-background-color;
box-shadow: none;
border: 1px solid $transparent;
font-weight: 500;
}
%icon-position {
top: 11px;
left: 11px;
width: 20px;
height: 20px;
}

View File

@@ -1,7 +1,8 @@
// studio - elements - side drawers
// ====================
.drawer-cover {
.drawer-cover,
.picker-cover {
@extend %ui-depth3;
display: none;
@@ -13,7 +14,8 @@
background: rgba(0, 0, 0, 0.8);
}
.drawer-cover.gray-cover {
.drawer-cover.gray-cover,
.picker-cover.gray-cover {
background: rgba(112, 112, 112, 0.8);
}
@@ -29,6 +31,20 @@
background-color: $gray-l4;
}
body.drawer-open {
body.drawer-open,
body.picker-open {
overflow: hidden;
}
.picker {
@extend %ui-depth4;
display: none;
position: fixed;
top: 1em;
left: 1em;
right: 1em;
bottom: 1em;
background-color: $gray-l4;
}

View File

@@ -140,12 +140,13 @@
position: relative;
display: inline-block;
width: ($baseline*6.25);
height: ($baseline*5);
height: ($baseline*7);
margin-bottom: ($baseline/2);
box-shadow: 0 1px 1px $shadow, 0 1px 0 rgba(255, 255, 255, 0.4) inset;
border-radius: ($baseline/4);
padding: 0;
text-align: center;
vertical-align: top;
@extend %btn-primary-green;
@@ -154,6 +155,21 @@
display: block;
color: $white;
}
.beta {
margin-top: 3px;
margin-bottom: -20px;
padding: 4px;
display: inline-block;
color: $uxpl-green-base;
background-color: theme-color("inverse");
border-color: theme-color("inverse");
border-radius: 3px;
font-size: 90%;
line-height: 90%;
}
}
}
}

View File

@@ -304,3 +304,12 @@ $state-danger-bg: #f2dede !default;
$state-danger-border: darken($state-danger-bg, 5%) !default;
$text-dark-black-blue: #2c3e50;
$primary: #0a3055 !default;
$btn-border-radius: 6px !default;
$input-border-radius: 6px !default;
$text-color: #454545 !default;
$light-background-color: #e1dddb !default;
$border-color: #707070 !default;
$base-font-size: 18px !default;
$dark: #212529;

View File

@@ -562,7 +562,71 @@
}
}
// Modal for previewing changes to a library-sourced block
// cms/static/js/views/modals/preview_v2_library_changes.js
.modal-lib-preview {
.modal-content {
padding: 0 !important;
& > iframe {
width: 100%;
min-height: 450px;
background: url('#{$static-path}/images/spinner.gif') center center no-repeat;
}
}
}
.ltiLaunchFrame{
width:100%;
height:100%
}
// Modal for adding components from libary using component picker
// cms/static/js/views/components/add_library_content_with_picker.js
.modal-add-component-picker {
top: 10%;
.modal-content {
padding: 0 !important;
& > iframe {
width: 100%;
min-height: 80vh;
background: url('#{$static-path}/images/spinner.gif') center center no-repeat;
}
}
}
.modal-alert {
display: flex;
align-items: center;
padding: $baseline;
margin-bottom: $baseline;
&.warning {
background-color: $state-warning-bg;
.fa-warning {
color: $orange;
margin-right: $baseline;
}
}
.action-btn {
text-wrap: nowrap;
background-color: $black;
color: $white;
margin-left: auto;
padding: ($baseline/2);
.fa {
margin-left: ($baseline/4);
color: $white
}
&:hover,
&:focus,
&:active {
background-color: lighten($black, 20%);
}
}
}

View File

@@ -1,104 +0,0 @@
.maintenance-header {
text-align: center;
margin-top: 50px;
h2 {
margin-bottom: 10px;
}
}
.maintenance-content {
padding: 3rem 0;
.maintenance-list {
max-width: 1280px;
margin: 0 auto;
.view-list-container {
padding: 10px 15px;
background-color: #fff;
border-bottom: 1px solid #ddd;
&:hover {
background-color: #fafafa;
}
.view-name {
display: inline-block;
width: 20%;
float: left;
}
.view-desc {
display: inline-block;
width: 80%;
font-size: 15px;
}
}
}
.maintenance-form {
width: 60%;
margin: auto;
.result-list {
height: calc(100vh - 200px);
overflow: auto;
}
.result {
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
margin-top: 15px;
padding: 15px 30px;
background: #f9f9f9;
}
li {
font-size: 13px;
line-height: 9px;
}
.actions {
text-align: right;
}
.field-radio div {
display: inline-block;
margin-right: 10px;
}
div.error {
color: #f00;
margin-top: 10px;
font-size: 13px;
}
div.head-output {
font-size: 13px;
margin-bottom: 10px;
}
div.main-output {
color: #0a0;
font-size: 15px;
}
}
.announcement-container {
width: 100%;
text-align: center;
.announcement-item {
display: inline-block;
max-width: 300px;
min-width: 300px;
margin: 15px;
.announcement-content {
background-color: $body-bg;
text-align: center;
padding: 22px 33px;
}
}
}
}

View File

@@ -52,6 +52,7 @@ from openedx.core.djangolib.markup import HTML, Text
isUnitPage: ${is_unit_page | n, dump_js_escaped_json},
canEdit: true,
outlineURL: "${outline_url | n, js_escaped_string}",
libraryContentPickerUrl: "${library_content_picker_url | n, js_escaped_string}",
clipboardData: ${user_clipboard | n, dump_js_escaped_json},
}
);

View File

@@ -0,0 +1,278 @@
## coding=utf-8
## mako
##
## Studio view template for rendering the whole Unit in an iframe with
## XBlocks controls specifically for Authoring MFE. This template renders
## a chromeless version of a unit container without headers, footers,
## and a navigation bar.
<%! main_css = "style-main-v1" %>
<%! course_unit_mfe_iframe_css = "course-unit-mfe-iframe-bundle" %>
<%namespace name='static' file='static_content.html'/>
<%!
from django.urls import reverse
from django.utils.translation import gettext as _
from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
from cms.djangoapps.contentstore.helpers import xblock_type_display_name
from lms.djangoapps.branding import api as branding_api
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.js_utils import (
dump_js_escaped_json, js_escaped_string
)
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.release import RELEASE_LINE
%>
<%page expression_filter="h"/>
<!doctype html>
<!--[if lte IE 9]><html class="ie9 lte9" lang="${LANGUAGE_CODE}"><![endif]-->
<!--[if !IE]><<!--><html lang="${LANGUAGE_CODE}"><!--<![endif]-->
<head dir="${static.dir_rtl()}">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="openedx-release-line" content="${RELEASE_LINE}" />
<title>
${xblock.display_name_with_default} ${xblock_type_display_name(xblock)} |
% if context_course:
<% ctx_loc = context_course.location %>
${context_course.display_name_with_default} |
% elif context_library:
${context_library.display_name_with_default} |
% endif
${settings.STUDIO_NAME}
</title>
<%
jsi18n_path = "js/i18n/{language}/djangojs.js".format(language=LANGUAGE_CODE)
%>
% if getattr(settings, 'CAPTURE_CONSOLE_LOG', False):
<script type="text/javascript">
var oldOnError = window.onerror;
window.localStorage.setItem('console_log_capture', JSON.stringify([]));
window.onerror = function (message, url, lineno, colno, error) {
if (oldOnError) {
oldOnError.apply(this, arguments);
}
var messages = JSON.parse(window.localStorage.getItem('console_log_capture'));
messages.push([message, url, lineno, colno, (error || {}).stack]);
window.localStorage.setItem('console_log_capture', JSON.stringify(messages));
}
</script>
% endif
<script type="text/javascript" src="${static.url(jsi18n_path)}"></script>
% if settings.DEBUG:
## Provides a fallback for gettext functions in development environment
<script type="text/javascript" src="${static.url('js/src/gettext_fallback.js')}"></script>
% endif
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="path_prefix" content="${EDX_ROOT_URL}">
<% favicon_url = branding_api.get_favicon_url() %>
<link rel="icon" type="image/x-icon" href="${favicon_url}"/>
<%static:css group='style-vendor'/>
<%static:css group='style-vendor-tinymce-content'/>
<%static:css group='style-vendor-tinymce-skin'/>
% if uses_bootstrap:
<link rel="stylesheet" href="${static.url(self.attr.main_css)}" type="text/css" media="all" />
% else:
<%static:css group='${self.attr.main_css}'/>
% endif
<%static:css group='${self.attr.course_unit_mfe_iframe_css}'/>
<%include file="widgets/segment-io.html" />
% for template_name in templates:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
<script type="text/template" id="image-modal-tpl">
<%static:include path="common/templates/image-modal.underscore" />
</script>
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
## The following stylesheets are included for studio-frontend debugging.
## Remove this as part of studio frontend deprecation.
## https://github.com/openedx/studio-frontend/issues/381
% if not settings.STUDIO_FRONTEND_CONTAINER_URL:
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/common.min.css')}" />
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/editImageModal.min.css')}" />
% endif
</head>
<body class="${static.dir_rtl()} is-signedin course container view-container lang_${LANGUAGE_CODE}">
<a class="nav-skip" href="#main">${_("Skip to main content")}</a>
<%static:js group='base_vendor'/>
<%static:webpack entry="commons"/>
<script type="text/javascript">
window.baseUrl = "${settings.STATIC_URL | n, js_escaped_string}";
require.config({
baseUrl: window.baseUrl
});
</script>
<script type="text/javascript" src="${static.url("cms/js/require-config.js")}"></script>
<!-- view -->
<div class="wrapper wrapper-view" dir="${static.dir_rtl()}">
<%
banner_messages = list(PageLevelMessages.user_messages(request))
%>
% if banner_messages:
<div class="page-banner">
<div class="user-messages">
% for message in banner_messages:
<div class="alert ${message.css_class}" role="alert">
<span class="icon icon-alert fa ${message.icon_class}" aria-hidden="true"></span>
${HTML(message.message_html)}
</div>
% endfor
</div>
</div>
% endif
<main id="main" aria-label="Content" tabindex="-1">
<div id="content">
<div class="wrapper-content wrapper">
<div class="inner-wrapper">
<section class="content-area">
<article class="content-primary ${'content-primary-fullwidth' if is_fullwidth_content else ''}">
<%
assets_url = reverse('assets_handler', kwargs={'course_key_string': str(xblock_locator.course_key)})
%>
<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}" data-course-assets="${assets_url}">
</section>
<div class="ui-loading">
<p><span class="spin"><span class="icon fa fa-refresh" aria-hidden="true"></span></span> <span class="copy">${_("Loading")}</span></p>
</div>
</article>
</section>
</div>
</div>
</div>
</main>
</div>
<div id="page-prompt"></div>
% if context_course:
<%static:webpack entry="js/factories/context_course"/>
<script type="text/javascript">
window.course = new ContextCourse({
id: "${context_course.id | n, js_escaped_string}",
name: "${context_course.display_name_with_default | n, js_escaped_string}",
url_name: "${context_course.location.block_id | n, js_escaped_string}",
org: "${context_course.location.org | n, js_escaped_string}",
num: "${context_course.location.course | n, js_escaped_string}",
display_course_number: "${context_course.display_coursenumber | n, js_escaped_string}",
revision: "${context_course.location.branch | n, js_escaped_string}",
self_paced: ${ context_course.self_paced | n, dump_js_escaped_json },
is_custom_relative_dates_active: ${CUSTOM_RELATIVE_DATES.is_enabled(context_course.id) | n, dump_js_escaped_json},
start: ${context_course.start | n, dump_js_escaped_json},
discussions_settings: ${context_course.discussions_settings | n, dump_js_escaped_json}
});
</script>
% endif
<script type="text/javascript">
require(['js/factories/base'], function () {});
</script>
<%static:webpack entry="js/factories/container">
ContainerFactory(
${component_templates | n, dump_js_escaped_json},
${xblock_info | n, dump_js_escaped_json},
"${action | n, js_escaped_string}",
{
isUnitPage: ${is_unit_page | n, dump_js_escaped_json},
canEdit: true,
outlineURL: "${outline_url | n, js_escaped_string}",
clipboardData: ${user_clipboard | n, dump_js_escaped_json},
isIframeEmbed: true,
}
);
</%static:webpack>
</body>
## Initialize MutationObserver and ResizeObserver to update the iframe size.
## These are used to provide resize events for the Authoring MFE.
<script type="text/javascript">
(function() {
// If this view is rendered in an iframe within the authoring microfrontend app
// it will report the height of its contents to the parent window when the
// document loads, window resizes, or DOM mutates.
if (window !== window.parent) {
var lastHeight = window.parent[0].offsetHeight;
var lastWidth = window.parent[0].offsetWidth;
var contentElement = document.getElementById('content');
function dispatchResizeMessage(event) {
// Note: event is actually an Array of MutationRecord objects when fired from the MutationObserver
var isLoadEvent = event.type === 'load';
var newHeight = contentElement.offsetHeight;
var newWidth = contentElement.offsetWidth;
// Monitor for messages and checks if the message contains an id. If
// there is an id, then the location of the selected focus element
// is sent through its offset attribute. The offset will allow the
// page to scroll to the location of the focus element so that it is
// at the top of the page. Unique ids and names are required for
// proper scrolling.
window.addEventListener('message', function (event) {
if (event.data.hashName) {
var targetId = event.data.hashName;
var targetName = event.data.hashName.slice(1);
// Checks if the target uses an id or name to focus and gets offset.
var targetOffset = $(targetId).offset() || $(document.getElementsByName(targetName)[0]).offset();
window.parent.postMessage({ 'offset': targetOffset.top }, document.referrer);
}
})
window.parent.postMessage(
{
type: 'plugin.resize',
payload: {
width: newWidth,
height: newHeight,
}
}, document.referrer
);
lastHeight = newHeight;
lastWidth = newWidth;
// Within the authoring microfrontend the iframe resizes to match the
// height of this document and it should never scroll. It does scroll
// ocassionally when javascript is used to focus elements on the page
// before the parent iframe has been resized to match the content
// height. This window.scrollTo is an attempt to keep the content at the
// top of the page.
window.scrollTo(0, 0);
}
// Create an observer instance linked to the callback function
const observer = new MutationObserver(dispatchResizeMessage);
// Start observing the target node for configured mutations
observer.observe(document.body, { attributes: true, childList: true, subtree: true });
window.addEventListener('load', dispatchResizeMessage);
const resizeObserver = new ResizeObserver(dispatchResizeMessage);
resizeObserver.observe(document.body);
}
}());
</script>
</html>

View File

@@ -6,4 +6,7 @@
<span class="large-template-icon large-<%- type %>-icon"></span>
<span class="sr"> <%- gettext("Add Component:") %></span>
<span class="name"><%- display_name %></span>
<% if (beta) { %>
<span class="beta"><%- gettext("Beta") %></span>
<% } %>
</button>

View File

@@ -0,0 +1,18 @@
<div class="modal-alert warning" role="alert">
<span class="fa fa-lg fa-warning" aria-hidden="true"/><span class="sr"><%- gettext("Warning") %></span>
<div class="message-content">
<strong><%- gettext("You are editing content from a Library.") %></strong>
<p>
<%- gettext("Edits made here will only be reflected in this course. These edits may be overridden later if updates are accepted.") %>
</p>
</div>
<a
href="<%- upstreamLink %>"
target="_blank"
class="btn action-btn"
>
<%- gettext('View in Library') %>
<span class="fa fa-external-link" aria-hidden="true" />
</a>
</div>

View File

@@ -1,40 +0,0 @@
<%page expression_filter="h"/>
<%inherit file="base.html" />
<%namespace name='static' file='../static_content.html'/>
<%!
from django.utils.translation import gettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%block name="title">${_('Delete Announcement')}</%block>
<%block name="viewtitle">
<h3 class="info-course">
<span>${_('Delete Announcement')}</span>
</h3>
</%block>
<%block name="viewcontent">
<section class="container maintenance-content">
<div id="delete-announcement-form" class="studio-view maintenance-form">
<div class="container-message wrapper-message">
<div class="message has-warnings" aria-hidden="true">
<p class="warning">
<span class="icon fa fa-warning"></span>
${_('Are you sure you want to delete this Announcement?')}
</p>
</div>
</div>
<form action="" method="post" class="form-create announcement-container">
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
<div class="announcement-item">
<div class="announcement-content">
## xss-lint: disable=mako-invalid-html-filter
${object.content | n}
</div>
</div>
<div class="actions">
<button type="submit" class="action action-primary" value="Confirm">${_('Confirm')}</button>
</div>
</form>
</div>
</section>
</%block>

View File

@@ -1,50 +0,0 @@
<%page expression_filter="h"/>
<%inherit file="base.html" />
<%namespace name='static' file='../static_content.html'/>
<%!
from django.utils.translation import gettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%block name="title">${_('Edit Announcement')}</%block>
<%block name="viewtitle">
<h3 class="info-course">
<span>${_('Edit Announcement')}</span>
</h3>
</%block>
<%block name="viewcontent">
<section class="container maintenance-content">
<div class="container studio-view maintenance-form">
<form class="form-create" method="post" action="${action_url}">
<div class="wrapper-form announcement-container">
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
<div class="announcement-item">
## xss-lint: disable=mako-invalid-html-filter
${form.as_p() | n}
</div>
<div class="actions">
<button type="submit" class="action action-primary">${_('Save')}</button>
</div>
</div>
</form>
</div>
</section>
</%block>
<%block name="header_extras">
<script src="${STATIC_URL}js/vendor/tinymce/js/tinymce/tinymce.full.min.js"></script>
<script type="text/javascript">
tinymce.init({
selector: "#id_content",
height: 450,
skin: "studio-tmce5",
branding: false,
plugins: [
"advlist autolink lists link image charmap print anchor",
"searchreplace visualblocks code",
"insertdatetime media table contextmenu paste"
],
toolbar: "undo redo styleselect bold italic alignleft aligncenter alignright alignjustify bullist numlist outdent indent link image"
});
</script>
</%block>

View File

@@ -1,59 +0,0 @@
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<%!
from django.urls import reverse
from django.utils.translation import gettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<div class="studio-view maintenance-form">
<div class="form-create">
<div class="announcement-container">
% for announcement in announcement_list:
<div class="announcement-item">
<div class="announcement-content">
## xss-lint: disable=mako-invalid-html-filter
${announcement.content | n}
</div>
<div class="actions-list">
<span>
% if announcement.active:
Active <span class="icon fa fa-check-square-o" aria-hidden="true" />
% else:
Inactive <span class="icon fa fa-square-o" aria-hidden="true" />
% endif
</span>
<a class="action-item announcement-edit"
href="${ reverse('maintenance:announcement_edit', kwargs={'pk': announcement.pk}) }">
<button class="btn-default">Edit</button>
</a>
<a class="action-item announcement-delete"
href="${ reverse('maintenance:announcement_delete', kwargs={'pk': announcement.pk}) }">
<button class="btn-default">Delete</button>
</a>
</div>
</div>
% endfor
</div>
<div class="actions">
<a href="${ reverse('maintenance:announcement_create') }">
<button class="action action-primary">${_('Create New')}</button>
</a>
% if is_paginated:
% if page_obj.has_previous():
<a href="${ reverse('maintenance:announcement_index', kwargs={'page': page_obj.previous_page_number()}) }">
<button class="action action-secondary">${_('previous')}</button>
</a>
% endif
% if page_obj.has_next():
<a href="${ reverse('maintenance:announcement_index', kwargs={'page': page_obj.next_page_number()}) }">
<button class="action action-secondary">${_('next')}</button>
</a>
% endif
% endif
</div>
</div>
</div>

View File

@@ -1,33 +0,0 @@
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<%!
from django.utils.translation import gettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<div id="force-published-form" class="wrap-instructor-info studio-view maintenance-form">
<form id="force_publish" class="form-create" method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
<div class="wrapper-form">
<fieldset>
<legend class="sr">${_("Required data to force publish course.")}</legend>
<div class="list-input">
<div id="course-id-container" class="field text required">
<label for="course-id">${_('Course ID')}</label>
<input id="course-id" type="text" name="course-id" aria-describedby="course-id-desc" required />
<div id="course-id-desc" class="tip tip-stacked">${_('course-v1:edX+DemoX+Demo_Course')}</div>
<div class="wrapper-error"></div>
</div>
</div>
</fieldset>
</div>
<div class="actions">
<button type="submit" id="submit_force_publish" class="action action-primary">${_('Force Publish Course')}
</button>
<button id="reset-button" class="action action-secondary action-cancel"
aria-describedby="reset-values-desc">${_('Reset')}</button>
<span id="reset-values-desc" class="is-hidden">${_('Reset values')}</span>
</div>
</form>
<div id="result-error"><div class="wrapper-error"></div></div>
<div id="result-container" class="result-container"></div>
</div>

View File

@@ -1,21 +0,0 @@
<%page expression_filter="h"/>
<%inherit file="../base.html" />
<%def name='online_help_token()'><% return 'maintenance' %></%def>
<%namespace name='static' file='../static_content.html'/>
<%!
from django.urls import reverse
from django.utils.translation import gettext as _
%>
<%block name="content">
<div class="wrapper-content wrapper">
<div class="maintenance-header">
<h2>
<a href="${reverse('maintenance:maintenance_index')}">
<span>${_('Maintenance Dashboard')}</span>
</a>
</h2>
<%block name="viewtitle">
</%block>
</div>
<%block name="viewcontent"></%block>
</%block>

Some files were not shown because too many files have changed in this diff Show More