diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index 0acc1ebdc8fa..784f607f06ba 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -238,6 +238,7 @@ "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/", diff --git a/cms/djangoapps/contentstore/migrations/0010_publishableentitylink_downstream_parent_usage_key.py b/cms/djangoapps/contentstore/migrations/0010_publishableentitylink_downstream_parent_usage_key.py new file mode 100644 index 000000000000..bcc9a7cb2ccd --- /dev/null +++ b/cms/djangoapps/contentstore/migrations/0010_publishableentitylink_downstream_parent_usage_key.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.18 on 2025-02-19 23:32 + +from django.db import migrations +from opaque_keys.edx.django.models import UsageKeyField +from opaque_keys.edx.keys import UsageKey + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentstore', '0009_learningcontextlinksstatus_publishableentitylink'), + ] + + operations = [ + migrations.AddField( + model_name='publishableentitylink', + name='downstream_parent_usage_key', + field=UsageKeyField( + db_index=True, + # Adds a default invalid value to the field to prevent the migration from failing + default=UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@vertical+block@invalid'), + max_length=255 + ), + ), + migrations.AlterField( + model_name='publishableentitylink', + name='downstream_parent_usage_key', + field=UsageKeyField(db_index=True, max_length=255), + ), + ] diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index 6a1750b8e1c8..d9b1972b14bb 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -7,6 +7,7 @@ from config_models.models import ConfigurationModel from django.db import models +from django.db.models import QuerySet from django.db.models.fields import IntegerField, TextField from django.utils.translation import gettext_lazy as _ from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField @@ -105,6 +106,8 @@ class PublishableEntityLink(models.Model): # A downstream entity can only link to single upstream entity # whereas an entity can be upstream for multiple downstream entities. downstream_usage_key = UsageKeyField(max_length=255, unique=True) + # Search by parent key (i.e., unit key) + downstream_parent_usage_key = UsageKeyField(max_length=255, db_index=True) # Search by course/downstream key downstream_context_key = CourseKeyField(max_length=255, db_index=True) version_synced = models.IntegerField() @@ -115,6 +118,25 @@ class PublishableEntityLink(models.Model): def __str__(self): return f"{self.upstream_usage_key}->{self.downstream_usage_key}" + @property + def upstream_version(self) -> int | None: + """ + Returns upstream block version number if available. + """ + version_num = None + if hasattr(self.upstream_block, 'published'): + if hasattr(self.upstream_block.published, 'version'): + if hasattr(self.upstream_block.published.version, 'version_num'): + version_num = self.upstream_block.published.version.version_num + return version_num + + @property + def upstream_context_title(self) -> str: + """ + Returns upstream context title. + """ + return self.upstream_block.learning_package.title + class Meta: verbose_name = _("Publishable Entity Link") verbose_name_plural = _("Publishable Entity Links") @@ -127,6 +149,7 @@ def update_or_create( upstream_usage_key: UsageKey, upstream_context_key: str, downstream_usage_key: UsageKey, + downstream_parent_usage_key: UsageKey, downstream_context_key: CourseKey, version_synced: int, version_declined: int | None = None, @@ -141,6 +164,7 @@ def update_or_create( 'upstream_usage_key': upstream_usage_key, 'upstream_context_key': upstream_context_key, 'downstream_usage_key': downstream_usage_key, + 'downstream_parent_usage_key': downstream_parent_usage_key, 'downstream_context_key': downstream_context_key, 'version_synced': version_synced, 'version_declined': version_declined, @@ -170,6 +194,27 @@ def update_or_create( link.save() return link + @classmethod + def get_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySet["PublishableEntityLink"]: + """ + Get all links for given downstream context, preselects related published version and learning package. + """ + return cls.objects.filter( + downstream_context_key=downstream_context_key + ).select_related( + "upstream_block__published__version", + "upstream_block__learning_package" + ) + + @classmethod + def get_by_upstream_usage_key(cls, upstream_usage_key: UsageKey) -> QuerySet["PublishableEntityLink"]: + """ + Get all downstream context keys for given upstream usage key + """ + return cls.objects.filter( + upstream_usage_key=upstream_usage_key, + ) + class LearningContextLinksStatusChoices(models.TextChoices): """ diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py index 6e102bab44a1..83bc02b685e4 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py @@ -1,3 +1,4 @@ """Module for v2 serializers.""" +from cms.djangoapps.contentstore.rest_api.v2.serializers.downstreams import PublishableEntityLinksSerializer from cms.djangoapps.contentstore.rest_api.v2.serializers.home import CourseHomeTabSerializerV2 diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py new file mode 100644 index 000000000000..930408040e67 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py @@ -0,0 +1,28 @@ +""" +Serializers for upstream -> downstream entity links. +""" + +from rest_framework import serializers + +from cms.djangoapps.contentstore.models import PublishableEntityLink + + +class PublishableEntityLinksSerializer(serializers.ModelSerializer): + """ + Serializer for publishable entity links. + """ + upstream_context_title = serializers.CharField(read_only=True) + upstream_version = serializers.IntegerField(read_only=True) + ready_to_sync = serializers.SerializerMethodField() + + def get_ready_to_sync(self, obj): + """Calculate ready_to_sync field""" + return bool( + obj.upstream_version and + obj.upstream_version > (obj.version_synced or 0) and + obj.upstream_version > (obj.version_declined or 0) + ) + + class Meta: + model = PublishableEntityLink + exclude = ['upstream_block', 'uuid'] diff --git a/cms/djangoapps/contentstore/rest_api/v2/urls.py b/cms/djangoapps/contentstore/rest_api/v2/urls.py index 3e653d07fbcf..4c58ad9c57b1 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/urls.py @@ -3,7 +3,8 @@ from django.conf import settings from django.urls import path, re_path -from cms.djangoapps.contentstore.rest_api.v2.views import home, downstreams +from cms.djangoapps.contentstore.rest_api.v2.views import downstreams, home + app_name = "v2" urlpatterns = [ @@ -23,6 +24,16 @@ downstreams.DownstreamView.as_view(), name="downstream" ), + re_path( + f'^upstreams/{settings.COURSE_KEY_PATTERN}$', + downstreams.UpstreamListView.as_view(), + name='upstream-list' + ), + re_path( + f'^upstream/{settings.USAGE_KEY_PATTERN}/downstream-contexts$', + downstreams.DownstreamContextListView.as_view(), + name='downstream-context-list' + ), re_path( fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$', downstreams.SyncFromUpstreamView.as_view(), diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index 46e67e87ea0c..c7fa62c2c082 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -40,6 +40,11 @@ 400: Downstream block is not linked to upstream content. 404: Downstream block not found or user lacks permission to edit it. + /api/contentstore/v2/upstream/{usage_key_string}/downstream-contexts + + GET: List all downstream contexts (Courses) linked to a library block. + 200: A list of Course IDs and their display names and URLs. + # 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 @@ -58,23 +63,36 @@ """ import logging +from itertools import groupby from attrs import asdict as attrs_asdict from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.utils.translation import gettext_lazy as _ from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.keys import CourseKey, 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.djangoapps.contentstore.helpers import import_static_assets_for_library_sync +from cms.djangoapps.contentstore.models import PublishableEntityLink +from cms.djangoapps.contentstore.rest_api.v2.serializers import PublishableEntityLinksSerializer +from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url from cms.lib.xblock.upstream_sync import ( - UpstreamLink, UpstreamLinkException, NoUpstream, BadUpstream, BadDownstream, - fetch_customizable_fields, sync_from_upstream, decline_sync, sever_upstream_link + BadDownstream, + BadUpstream, + NoUpstream, + UpstreamLink, + UpstreamLinkException, + decline_sync, + fetch_customizable_fields, + sever_upstream_link, + sync_from_upstream, ) -from cms.djangoapps.contentstore.helpers import import_static_assets_for_library_sync -from common.djangoapps.student.auth import has_studio_write_access, has_studio_read_access +from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.view_utils import ( DeveloperErrorViewMixin, view_auth_classes, @@ -82,7 +100,6 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError - logger = logging.getLogger(__name__) @@ -111,6 +128,97 @@ class _AuthenticatedRequest(Request): # ... +@view_auth_classes() +class UpstreamListView(DeveloperErrorViewMixin, APIView): + """ + Serves course->library publishable entity links + """ + def get(self, request: _AuthenticatedRequest, course_key_string: str): + """ + Fetches publishable entity links for given course key + """ + try: + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError as exc: + raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc + links = PublishableEntityLink.get_by_downstream_context(downstream_context_key=course_key) + serializer = PublishableEntityLinksSerializer(links, many=True) + return Response(serializer.data) + + +@view_auth_classes() +class DownstreamContextListView(DeveloperErrorViewMixin, APIView): + """ + Serves library block->course->container links + """ + def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response: + """ + Fetches downstream links for given publishable entity + """ + try: + usage_key = UsageKey.from_string(usage_key_string) + except InvalidKeyError as exc: + raise ValidationError(detail=f"Malformed usage key: {usage_key_string}") from exc + + links = ( + PublishableEntityLink + .get_by_upstream_usage_key(upstream_usage_key=usage_key) + .order_by("downstream_context_key", "downstream_parent_usage_key") + .values("downstream_usage_key", "downstream_context_key", "downstream_parent_usage_key") + ) + + context_key_list = set(link["downstream_context_key"] for link in links) + + courses_display_name = dict( + CourseOverview.objects.filter(id__in=context_key_list).values_list('id', 'display_name') + ) + + result = [] + for context_key, links_by_context in groupby(links, lambda x: x["downstream_context_key"]): + if context_key not in courses_display_name: + raise BadDownstream(_("Course {context_key} not found").format(context_key=context_key)) + + course_link = { + "id": str(context_key), + "display_name": courses_display_name[context_key], + "url": reverse_course_url("course_handler", context_key), + "containers": [] + } + + for parent_key, links_by_unit in groupby(links_by_context, lambda x: x["downstream_parent_usage_key"]): + try: + parent = modulestore().get_item(parent_key) + + parent_link = { + "id": str(parent_key), + "display_name": parent.display_name, + "url": reverse_usage_url("container_handler", parent_key), + "links": [] + } + + for downstream_link in links_by_unit: + parent_link["links"].append({ + "id": str(downstream_link["downstream_usage_key"]), + }) + + # Don't include empty containers + if parent_link["links"]: + course_link["containers"].append(parent_link) + + except ItemNotFoundError: + logger.exception( + "Unit %s not found in course %s", + parent_key, + context_key + ) + + # Don't include empty courses + if course_link["containers"]: + result.append(course_link) + + return Response(result) + + @view_auth_classes(is_authenticated=True) class DownstreamView(DeveloperErrorViewMixin, APIView): """ diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index 31877b9153d5..d3a707599ce7 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -1,21 +1,25 @@ """ Unit tests for /api/contentstore/v2/downstreams/* JSON APIs. """ +from datetime import datetime, timezone from unittest.mock import patch + from django.conf import settings +from freezegun import freeze_time from cms.djangoapps.contentstore.helpers import StaticFileNotices -from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream +from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory 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_REF = "lb:OpenedX:CSPROB3:video:843b4c73-1e2d-4ced-a0ff-24e503cdb3e4" +MOCK_HTML_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, @@ -38,24 +42,47 @@ def _get_upstream_link_bad(_downstream): raise BadUpstream(MOCK_UPSTREAM_ERROR) -class _DownstreamViewTestMixin: +class _BaseDownstreamViewTestMixin: """ 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.now = datetime.now(timezone.utc) + freezer = freeze_time(self.now) + self.addCleanup(freezer.stop) + freezer.start() + self.maxDiff = 2000 self.course = CourseFactory.create() + CourseOverviewFactory.create(id=self.course.id, display_name=self.course.display_name) 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.unit = BlockFactory.create(category='vertical', parent=sequential) + self.regular_video_key = BlockFactory.create(category='video', parent=self.unit).usage_key self.downstream_video_key = BlockFactory.create( - category='video', parent=unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123, + category='video', parent=self.unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123, ).usage_key + self.downstream_html_key = BlockFactory.create( + category='html', parent=self.unit, upstream=MOCK_HTML_UPSTREAM_REF, upstream_version=1, + ).usage_key + + self.another_course = CourseFactory.create(display_name="Another Course") + CourseOverviewFactory.create(id=self.another_course.id, display_name=self.another_course.display_name) + another_chapter = BlockFactory.create(category="chapter", parent=self.another_course) + another_sequential = BlockFactory.create(category="sequential", parent=another_chapter) + self.another_unit = BlockFactory.create(category="vertical", parent=another_sequential) + self.another_video_keys = [] + for _ in range(3): + # Adds 3 videos linked to the same upstream + self.another_video_keys.append( + BlockFactory.create( + category="video", parent=self.another_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") @@ -63,6 +90,11 @@ def setUp(self): def call_api(self, usage_key_string): raise NotImplementedError + +class SharedErrorTestCases(_BaseDownstreamViewTestMixin): + """ + Shared error test cases. + """ def test_404_downstream_not_found(self): """ Do we raise 404 if the specified downstream block could not be loaded? @@ -82,7 +114,7 @@ def test_404_downstream_not_accessible(self): assert "not found" in response.data["developer_message"] -class GetDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase): +class GetDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase): """ Test that `GET /api/v2/contentstore/downstreams/...` inspects a downstream's link to an upstream. """ @@ -128,7 +160,7 @@ def test_200_no_upstream(self): assert response.data['upstream_link'] is None -class PutDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase): +class PutDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase): """ Test that `PUT /api/v2/contentstore/downstreams/...` edits a downstream's link to an upstream. """ @@ -185,7 +217,7 @@ def test_400(self, sync: str): assert video_after.upstream is None -class DeleteDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase): +class DeleteDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase): """ Test that `DELETE /api/v2/contentstore/downstreams/...` severs a downstream's link to an upstream. """ @@ -214,7 +246,7 @@ def test_204_no_upstream(self, mock_sever): assert mock_sever.call_count == 1 -class _DownstreamSyncViewTestMixin(_DownstreamViewTestMixin): +class _DownstreamSyncViewTestMixin(SharedErrorTestCases): """ Shared tests between the /api/contentstore/v2/downstreams/.../sync endpoints. """ @@ -277,3 +309,101 @@ def test_204(self, mock_decline_sync): response = self.call_api(self.downstream_video_key) assert response.status_code == 204 assert mock_decline_sync.call_count == 1 + + +class GetUpstreamViewTest(_BaseDownstreamViewTestMixin, SharedModuleStoreTestCase): + """ + Test that `GET /api/v2/contentstore/upstreams/...` returns list of links in given downstream context i.e. course. + """ + def call_api(self, usage_key_string): + return self.client.get(f"/api/contentstore/v2/upstreams/{usage_key_string}") + + def test_200_all_upstreams(self): + """ + Returns all upstream links for given course + """ + self.client.login(username="superuser", password="password") + response = self.call_api(self.course.id) + assert response.status_code == 200 + data = response.json() + date_format = self.now.isoformat().split("+")[0] + 'Z' + expected = [ + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_video_key), + 'downstream_parent_usage_key': str(self.unit.usage_key), + 'id': 1, + 'ready_to_sync': False, + 'updated': date_format, + 'upstream_context_key': MOCK_LIB_KEY, + 'upstream_usage_key': MOCK_UPSTREAM_REF, + 'upstream_version': None, + 'version_declined': None, + 'version_synced': 123 + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_html_key), + 'downstream_parent_usage_key': str(self.unit.usage_key), + 'id': 2, + 'ready_to_sync': False, + 'updated': date_format, + 'upstream_context_key': MOCK_LIB_KEY, + 'upstream_usage_key': MOCK_HTML_UPSTREAM_REF, + 'upstream_version': None, + 'version_declined': None, + 'version_synced': 1, + }, + ] + self.assertListEqual(data, expected) + + +class GetDownstreamContextsTest(_BaseDownstreamViewTestMixin, SharedModuleStoreTestCase): + """ + Test that `GET /api/v2/contentstore/upstream/:usage_key/downstream-contexts returns list of + link contexts (i.e. courses) in given upstream entity (i.e. library block). + """ + def call_api(self, usage_key_string): + return self.client.get(f"/api/contentstore/v2/upstream/{usage_key_string}/downstream-contexts") + + def test_200_downstream_context_list(self): + """ + Returns all downstream courses for given library block + """ + self.client.login(username="superuser", password="password") + response = self.call_api(MOCK_UPSTREAM_REF) + assert response.status_code == 200 + data = response.json() + expected = [ + { + "id": str(self.course.id), + "display_name": str(self.course.display_name), + "url": f"/course/{str(self.course.id)}", + "containers": [ + { + "id": str(self.unit.usage_key), + "display_name": str(self.unit.display_name), + "url": f"/container/{str(self.unit.usage_key)}", + "links": [ + {"id": str(self.downstream_video_key)}, + ], + }, + ], + }, + { + "id": str(self.another_course.id), + "display_name": str(self.another_course.display_name), + "url": f"/course/{str(self.another_course.id)}", + "containers": [ + { + "id": str(self.another_unit.usage_key), + "display_name": str(self.another_unit.display_name), + "url": f"/container/{str(self.another_unit.usage_key)}", + "links": [{"id": str(key)} for key in self.another_video_keys], + }, + ], + }, + ] + self.assertListEqual(data, expected) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 79d8f757a7d1..5a828c098aa2 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -523,6 +523,18 @@ def get_custom_pages_url(course_locator) -> str: return custom_pages_url +def get_course_libraries_url(course_locator) -> str: + """ + Gets course authoring microfrontend URL for custom pages view. + """ + url = None + if libraries_v2_enabled(): + mfe_base_url = get_course_authoring_url(course_locator) + if mfe_base_url: + url = f'{mfe_base_url}/course/{course_locator}/libraries' + return url + + def get_taxonomy_list_url() -> str | None: """ Gets course authoring microfrontend URL for taxonomy list page view. @@ -2359,7 +2371,7 @@ def get_xblock_render_context(request, block): return "" -def create_or_update_xblock_upstream_link(xblock, course_key: str | CourseKey, created: datetime | None = None): +def create_or_update_xblock_upstream_link(xblock, course_key: str | CourseKey, created: datetime | None = None) -> None: """ Create or update upstream->downstream link in database for given xblock. """ @@ -2375,8 +2387,9 @@ def create_or_update_xblock_upstream_link(xblock, course_key: str | CourseKey, c lib_component, upstream_usage_key=xblock.upstream, upstream_context_key=str(upstream_usage_key.context_key), - downstream_context_key=course_key, downstream_usage_key=xblock.usage_key, + downstream_parent_usage_key=xblock.parent, + downstream_context_key=course_key, version_synced=xblock.upstream_version, version_declined=xblock.upstream_version_declined, created=created, diff --git a/cms/djangoapps/maintenance/__init__.py b/cms/djangoapps/maintenance/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cms/djangoapps/maintenance/tests.py b/cms/djangoapps/maintenance/tests.py new file mode 100644 index 000000000000..51f776b00afa --- /dev/null +++ b/cms/djangoapps/maintenance/tests.py @@ -0,0 +1,194 @@ +""" +Tests for the maintenance app views. +""" + + +import ddt +from django.conf import settings +from django.urls import reverse + +from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from openedx.features.announcements.models import Announcement +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order + +from .views import 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 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, '
') + + 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, '
') + 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}) diff --git a/cms/djangoapps/maintenance/urls.py b/cms/djangoapps/maintenance/urls.py new file mode 100644 index 000000000000..2937727bfa39 --- /dev/null +++ b/cms/djangoapps/maintenance/urls.py @@ -0,0 +1,23 @@ +""" +URLs for the maintenance app. +""" + +from django.urls import path, re_path + +from .views import ( + AnnouncementCreateView, + AnnouncementDeleteView, + AnnouncementEditView, + AnnouncementIndexView, + MaintenanceIndexView +) + +app_name = 'cms.djangoapps.maintenance' + +urlpatterns = [ + path('', MaintenanceIndexView.as_view(), name='maintenance_index'), + re_path(r'^announcements/(?P\d+)?$', AnnouncementIndexView.as_view(), name='announcement_index'), + path('announcements/create', AnnouncementCreateView.as_view(), name='announcement_create'), + re_path(r'^announcements/edit/(?P\d+)?$', AnnouncementEditView.as_view(), name='announcement_edit'), + path('announcements/delete/', AnnouncementDeleteView.as_view(), name='announcement_delete'), +] diff --git a/cms/djangoapps/maintenance/views.py b/cms/djangoapps/maintenance/views.py new file mode 100644 index 000000000000..1ec0f9c5cff2 --- /dev/null +++ b/cms/djangoapps/maintenance/views.py @@ -0,0 +1,190 @@ +""" +Views for the maintenance app. +""" + + +import logging + +from django.core.validators import ValidationError +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.edx.keys import CourseKey + +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 = { + '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 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' diff --git a/cms/envs/common.py b/cms/envs/common.py index a39e06565166..2f81a43d984b 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1699,6 +1699,8 @@ # New (Learning-Core-based) XBlock runtime 'openedx.core.djangoapps.xblock.apps.StudioXBlockAppConfig', + # Maintenance tools + 'cms.djangoapps.maintenance', 'openedx.core.djangoapps.util.apps.UtilConfig', # Tracking diff --git a/cms/static/js/views/xblock_editor.js b/cms/static/js/views/xblock_editor.js index 40ab22d022fc..52d08dc76fb4 100644 --- a/cms/static/js/views/xblock_editor.js +++ b/cms/static/js/views/xblock_editor.js @@ -78,7 +78,7 @@ function($, _, gettext, BaseView, XBlockView, MetadataView, MetadataCollection) el: metadataEditor, collection: new MetadataCollection(models) }); - if (xblock.setMetadataEditor) { + if (xblock && xblock.setMetadataEditor) { xblock.setMetadataEditor(metadataView); } } diff --git a/cms/static/sass/_build-v1.scss b/cms/static/sass/_build-v1.scss index 7aedd0c6b8a3..178f6b167473 100644 --- a/cms/static/sass/_build-v1.scss +++ b/cms/static/sass/_build-v1.scss @@ -77,6 +77,7 @@ @import 'views/group-configuration'; @import 'views/video-upload'; @import 'views/certificates'; +@import 'views/maintenance'; // +Base - Contexts // ==================== diff --git a/cms/static/sass/views/_maintenance.scss b/cms/static/sass/views/_maintenance.scss new file mode 100644 index 000000000000..58d1b7494751 --- /dev/null +++ b/cms/static/sass/views/_maintenance.scss @@ -0,0 +1,104 @@ +.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; + } + } + } +} diff --git a/cms/templates/maintenance/_announcement_delete.html b/cms/templates/maintenance/_announcement_delete.html new file mode 100644 index 000000000000..0397ef5a0bf8 --- /dev/null +++ b/cms/templates/maintenance/_announcement_delete.html @@ -0,0 +1,40 @@ +<%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 name="viewtitle"> +

+ ${_('Delete Announcement')} +

+ + +<%block name="viewcontent"> +
+
+
+ +
+
+ +
+
+ ## xss-lint: disable=mako-invalid-html-filter + ${object.content | n} +
+
+
+ +
+
+
+
+ diff --git a/cms/templates/maintenance/_announcement_edit.html b/cms/templates/maintenance/_announcement_edit.html new file mode 100644 index 000000000000..a9bee1c6fce2 --- /dev/null +++ b/cms/templates/maintenance/_announcement_edit.html @@ -0,0 +1,50 @@ +<%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 name="viewtitle"> +

+ ${_('Edit Announcement')} +

+ + +<%block name="viewcontent"> +
+
+
+
+ +
+ ## xss-lint: disable=mako-invalid-html-filter + ${form.as_p() | n} +
+
+ +
+
+
+
+
+ + +<%block name="header_extras"> + + + diff --git a/cms/templates/maintenance/_announcement_index.html b/cms/templates/maintenance/_announcement_index.html new file mode 100644 index 000000000000..68713c9986cc --- /dev/null +++ b/cms/templates/maintenance/_announcement_index.html @@ -0,0 +1,59 @@ +<%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 + +%> +
+
+
+ % for announcement in announcement_list: +
+
+ ## xss-lint: disable=mako-invalid-html-filter + ${announcement.content | n} +
+ +
+ + % if announcement.active: + Active
+
+ % endfor +
+
+ + + + % if is_paginated: + % if page_obj.has_previous(): + + + + % endif + + % if page_obj.has_next(): + + + + % endif + % endif +
+
+
diff --git a/cms/templates/maintenance/base.html b/cms/templates/maintenance/base.html new file mode 100644 index 000000000000..6979797a629c --- /dev/null +++ b/cms/templates/maintenance/base.html @@ -0,0 +1,21 @@ +<%page expression_filter="h"/> +<%inherit file="../base.html" /> +<%def name='online_help_token()'><% return 'maintenance' %> +<%namespace name='static' file='../static_content.html'/> +<%! +from django.urls import reverse +from django.utils.translation import gettext as _ +%> +<%block name="content"> +
+
+

+ + ${_('Maintenance Dashboard')} + +

+ <%block name="viewtitle"> + +
+<%block name="viewcontent"> + diff --git a/cms/templates/maintenance/container.html b/cms/templates/maintenance/container.html new file mode 100644 index 000000000000..319a57bfe995 --- /dev/null +++ b/cms/templates/maintenance/container.html @@ -0,0 +1,25 @@ +<%page expression_filter="h"/> +<%inherit file="base.html" /> +<%namespace name='static' file='../static_content.html'/> +<%! +from django.urls import reverse +from openedx.core.djangolib.js_utils import js_escaped_string +%> +<%block name="title">${view['name']} +<%block name="viewtitle"> +

+ ${view['name']} +

+ + +<%block name="viewcontent"> +
+ <%include file="_${view['slug']}.html"/> +
+ + +<%block name="requirejs"> + require(["js/maintenance/${view['slug'] | n, js_escaped_string}"], function(MaintenanceFactory) { + MaintenanceFactory("${reverse(view['url']) | n, js_escaped_string}"); + }); + diff --git a/cms/templates/maintenance/index.html b/cms/templates/maintenance/index.html new file mode 100644 index 000000000000..293cb90b4a9c --- /dev/null +++ b/cms/templates/maintenance/index.html @@ -0,0 +1,20 @@ +<%page expression_filter="h"/> +<%inherit file="base.html" /> +<%namespace name='static' file='../static_content.html'/> +<%! +from django.utils.translation import gettext as _ +from django.urls import reverse +%> +<%block name="title">${_('Maintenance Dashboard')} +<%block name="viewcontent"> +
+
    + % for view in views.values(): +
  • + ${view['name']} + ${view['description']} +
  • + % endfor +
+
+ diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 941fe8d5e6a6..af50a3c321a9 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -8,7 +8,7 @@ from urllib.parse import quote_plus from common.djangoapps.student.auth import has_studio_advanced_settings_access from cms.djangoapps.contentstore import toggles - from cms.djangoapps.contentstore.utils import get_pages_and_resources_url, get_course_outline_url, get_updates_url, get_files_uploads_url, get_video_uploads_url, get_schedule_details_url, get_grading_url, get_advanced_settings_url, get_import_url, get_export_url, get_studio_home_url, get_course_team_url, get_optimizer_url + from cms.djangoapps.contentstore.utils import get_pages_and_resources_url, get_course_outline_url, get_course_libraries_url, get_updates_url, get_files_uploads_url, get_video_uploads_url, get_schedule_details_url, get_grading_url, get_advanced_settings_url, get_import_url, get_export_url, get_studio_home_url, get_course_team_url, get_optimizer_url from openedx.core.djangoapps.discussions.config.waffle import ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND from openedx.core.djangoapps.lang_pref.api import header_language_selector_is_enabled, released_languages %> @@ -67,6 +67,7 @@

import_mfe_enabled = toggles.use_new_import_page(context_course.id) export_mfe_enabled = toggles.use_new_export_page(context_course.id) optimizer_enabled = toggles.enable_course_optimizer(context_course.id) + libraries_v2_enabled = toggles.libraries_v2_enabled() %>

@@ -104,6 +105,11 @@

${_("Course" ${_("Outline")} % endif + % if libraries_v2_enabled: + + % endif % if not updates_mfe_enabled: