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, '