Merge branch 'master' into kiran/remove-unused-task-code

This commit is contained in:
Kiran Chauhan
2025-11-08 16:26:59 +05:30
committed by GitHub
21 changed files with 493 additions and 122 deletions

View File

@@ -7,12 +7,12 @@ from itertools import chain
from config_models.models import ConfigurationModel
from django.db import models
from django.db.models import QuerySet, OuterRef, Case, When, Exists, Value, ExpressionWrapper
from django.db.models.fields import IntegerField, TextField, BooleanField
from django.db.models import Case, Exists, ExpressionWrapper, OuterRef, Q, QuerySet, Value, When
from django.db.models.fields import BooleanField, IntegerField, TextField
from django.db.models.functions import Coalesce
from django.db.models.lookups import GreaterThan
from django.utils.translation import gettext_lazy as _
from opaque_keys.edx.django.models import CourseKeyField, ContainerKeyField, UsageKeyField
from opaque_keys.edx.django.models import ContainerKeyField, CourseKeyField, UsageKeyField
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import LibraryContainerLocator
from openedx_learning.api.authoring import get_published_version
@@ -23,7 +23,6 @@ from openedx_learning.lib.fields import (
manual_date_time_field,
)
logger = logging.getLogger(__name__)
@@ -391,7 +390,7 @@ class ContainerLink(EntityLinkBase):
cls.objects.filter(**link_filter).select_related(*RELATED_FIELDS),
)
if ready_to_sync is not None:
result = result.filter(ready_to_sync=ready_to_sync)
result = result.filter(Q(ready_to_sync=ready_to_sync) | Q(ready_to_sync_from_children=ready_to_sync))
# Handle top-level parents logic
if use_top_level_parents:
@@ -436,6 +435,11 @@ class ContainerLink(EntityLinkBase):
),
then=1
),
# If upstream block was deleted, set ready_to_sync = True
When(
Q(upstream_container__publishable_entity__published__version__version_num__isnull=True),
then=1
),
default=0,
output_field=models.IntegerField()
)
@@ -457,6 +461,11 @@ class ContainerLink(EntityLinkBase):
),
then=1
),
# If upstream block was deleted, set ready_to_sync = True
When(
Q(upstream_block__publishable_entity__published__version__version_num__isnull=True),
then=1
),
default=0,
output_field=models.IntegerField()
)

View File

@@ -23,7 +23,7 @@ from common.djangoapps.student.roles import CourseStaffRole
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content_libraries import api as lib_api
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, ImmediateOnCommitMixin
from xmodule.modulestore.tests.django_utils import ImmediateOnCommitMixin, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
from .. import downstreams as downstreams_views
@@ -32,6 +32,7 @@ MOCK_UPSTREAM_ERROR = "your LibraryGPT subscription has expired"
URL_PREFIX = '/api/libraries/v2/'
URL_LIB_CREATE = URL_PREFIX
URL_LIB_BLOCKS = URL_PREFIX + '{lib_key}/blocks/'
URL_LIB_BLOCK = URL_PREFIX + 'blocks/{block_key}/'
URL_LIB_BLOCK_PUBLISH = URL_PREFIX + 'blocks/{block_key}/publish/'
URL_LIB_BLOCK_OLX = URL_PREFIX + 'blocks/{block_key}/olx/'
URL_LIB_CONTAINER = URL_PREFIX + 'containers/{container_key}/' # Get a container in this library
@@ -277,6 +278,10 @@ class _BaseDownstreamViewTestMixin:
data["slug"] = slug
return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), data, expect_response)
def _delete_component(self, block_key, expect_response=200):
""" Publish all changes in the specified container + children """
return self._api('delete', URL_LIB_BLOCK.format(block_key=block_key), None, expect_response)
class SharedErrorTestCases(_BaseDownstreamViewTestMixin):
"""
@@ -1503,3 +1508,109 @@ class GetDownstreamSummaryViewTest(
'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
}]
self.assertListEqual(data, expected)
class GetDownstreamDeletedUpstream(
_BaseDownstreamViewTestMixin,
ImmediateOnCommitMixin,
SharedModuleStoreTestCase,
):
"""
Test that parent container is marked ready_to_sync when even when the only change is a deleted component under it
"""
def call_api(
self,
course_id: str | None = None,
ready_to_sync: bool | None = None,
upstream_key: str | None = None,
item_type: str | None = None,
use_top_level_parents: bool | None = None,
):
data = {}
if course_id is not None:
data["course_id"] = str(course_id)
if ready_to_sync is not None:
data["ready_to_sync"] = str(ready_to_sync)
if upstream_key is not None:
data["upstream_key"] = str(upstream_key)
if item_type is not None:
data["item_type"] = str(item_type)
if use_top_level_parents is not None:
data["use_top_level_parents"] = str(use_top_level_parents)
return self.client.get("/api/contentstore/v2/downstreams/", data=data)
def test_delete_component_should_be_ready_to_sync(self):
"""
Test deleting a component from library should mark the entire section container ready to sync
"""
# Create blocks
section_id = self._create_container(self.library_id, "section", "section-12", "Section 12")["id"]
subsection_id = self._create_container(self.library_id, "subsection", "subsection-12", "Subsection 12")["id"]
unit_id = self._create_container(self.library_id, "unit", "unit-12", "Unit 12")["id"]
video_id = self._add_block_to_library(self.library_id, "video", "video-bar-13")["id"]
section_key = ContainerKey.from_string(section_id)
subsection_key = ContainerKey.from_string(subsection_id)
unit_key = ContainerKey.from_string(unit_id)
video_key = LibraryUsageLocatorV2.from_string(video_id)
# Set children
lib_api.update_container_children(section_key, [subsection_key], None)
lib_api.update_container_children(subsection_key, [unit_key], None)
lib_api.update_container_children(unit_key, [video_key], None)
self._publish_container(unit_id)
self._publish_container(subsection_id)
self._publish_container(section_id)
self._publish_library_block(video_id)
course = CourseFactory.create(display_name="Course New")
add_users(self.superuser, CourseStaffRole(course.id), self.course_user)
chapter = BlockFactory.create(
category='chapter', parent=course, upstream=section_id, upstream_version=2,
)
sequential = BlockFactory.create(
category='sequential',
parent=chapter,
upstream=subsection_id,
upstream_version=2,
top_level_downstream_parent_key=get_block_key_string(chapter.usage_key),
)
vertical = BlockFactory.create(
category='vertical',
parent=sequential,
upstream=unit_id,
upstream_version=2,
top_level_downstream_parent_key=get_block_key_string(chapter.usage_key),
)
BlockFactory.create(
category='video',
parent=vertical,
upstream=video_id,
upstream_version=1,
top_level_downstream_parent_key=get_block_key_string(chapter.usage_key),
)
self._delete_component(video_id)
self._publish_container(unit_id)
response = self.call_api(course_id=course.id, ready_to_sync=True, use_top_level_parents=True)
assert response.status_code == 200
data = response.json()['results']
assert len(data) == 1
date_format = self.now.isoformat().split("+")[0] + 'Z'
expected_results = {
'created': date_format,
'downstream_context_key': str(course.id),
'downstream_usage_key': str(chapter.usage_key),
'downstream_customized': [],
'id': 8,
'ready_to_sync': False,
'ready_to_sync_from_children': True,
'top_level_parent_usage_key': None,
'updated': date_format,
'upstream_context_key': self.library_id,
'upstream_context_title': self.library_title,
'upstream_key': section_id,
'upstream_type': 'container',
'upstream_version': 2,
'version_declined': None,
'version_synced': 2,
}
self.assertDictEqual(data[0], expected_results)

View File

@@ -5,11 +5,13 @@ Serializers for the Course to Library Import API.
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import LearningContextKey
from opaque_keys.edx.locator import LibraryLocatorV2
from openedx_learning.api.authoring_models import Collection
from rest_framework import serializers
from user_tasks.models import UserTaskStatus
from user_tasks.serializers import StatusSerializer
from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy
from cms.djangoapps.modulestore_migrator.models import ModulestoreMigration
from cms.djangoapps.modulestore_migrator.models import ModulestoreMigration, ModulestoreSource
class ModulestoreMigrationSerializer(serializers.Serializer):
@@ -173,3 +175,65 @@ class StatusWithModulestoreMigrationsSerializer(StatusSerializer):
fields = super().get_fields()
fields.pop('name', None)
return fields
class LibraryMigrationCourseSourceSerializer(serializers.ModelSerializer):
"""
Serializer for the source course of a library migration.
"""
display_name = serializers.SerializerMethodField()
class Meta:
model = ModulestoreSource
fields = ['key', 'display_name']
def get_display_name(self, obj):
"""
Return the display name of the source course
"""
return self.context["course_names"].get(str(obj.key), None)
class LibraryMigrationCollectionSerializer(serializers.ModelSerializer):
"""
Serializer for the target collection of a library migration.
"""
class Meta:
model = Collection
fields = ["key", "title"]
class LibraryMigrationCourseSerializer(serializers.ModelSerializer):
"""
Serializer for the course or legacylibrary migrations to V2 library.
"""
source = LibraryMigrationCourseSourceSerializer() # type: ignore[assignment]
target_collection = LibraryMigrationCollectionSerializer(required=False)
state = serializers.SerializerMethodField()
progress = serializers.SerializerMethodField()
class Meta:
model = ModulestoreMigration
fields = [
'source',
'target_collection',
'state',
'progress',
]
def get_state(self, obj: ModulestoreMigration):
"""
Return the state of the migration.
"""
if obj.is_failed or obj.task_status.state in [UserTaskStatus.FAILED, UserTaskStatus.CANCELED]:
return UserTaskStatus.FAILED
elif obj.task_status.state == UserTaskStatus.SUCCEEDED:
return UserTaskStatus.SUCCEEDED
return UserTaskStatus.IN_PROGRESS
def get_progress(self, obj: ModulestoreMigration):
"""
Return the progress of the migration.
"""
return obj.task_status.completed_steps / obj.task_status.total_steps

View File

@@ -3,10 +3,17 @@ Course to Library Import API v1 URLs.
"""
from rest_framework.routers import SimpleRouter
from .views import MigrationViewSet, BulkMigrationViewSet
from .views import BulkMigrationViewSet, LibraryCourseMigrationViewSet, MigrationViewSet
ROUTER = SimpleRouter()
ROUTER.register(r'migrations', MigrationViewSet, basename='migrations')
ROUTER.register(r'bulk_migration', BulkMigrationViewSet, basename='bulk-migration')
ROUTER.register(
r'library/(?P<lib_key_str>[^/.]+)/migrations/courses',
LibraryCourseMigrationViewSet,
basename='library-migrations',
)
urlpatterns = ROUTER.urls

View File

@@ -6,22 +6,30 @@ import logging
import edx_api_doc_tools as apidocs
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import LibraryLocatorV2
from rest_framework import status
from rest_framework.exceptions import ParseError
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework import status
from rest_framework.viewsets import GenericViewSet
from user_tasks.models import UserTaskStatus
from user_tasks.views import StatusViewSet
from cms.djangoapps.modulestore_migrator.api import start_migration_to_library, start_bulk_migration_to_library
from cms.djangoapps.modulestore_migrator.api import start_bulk_migration_to_library, start_migration_to_library
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content_libraries import api as lib_api
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from ...models import ModulestoreMigration
from .serializers import (
StatusWithModulestoreMigrationsSerializer,
ModulestoreMigrationSerializer,
BulkModulestoreMigrationSerializer,
LibraryMigrationCourseSerializer,
ModulestoreMigrationSerializer,
StatusWithModulestoreMigrationsSerializer,
)
log = logging.getLogger(__name__)
@@ -328,3 +336,55 @@ class BulkMigrationViewSet(StatusViewSet):
We disable this endpoint to avoid confusion.
"""
raise NotImplementedError
@apidocs.schema_for(
"list",
"List all course migrations to a library.",
responses={
201: LibraryMigrationCourseSerializer,
401: "The requester is not authenticated.",
403: "The requester does not have permission to access the library.",
},
)
class LibraryCourseMigrationViewSet(GenericViewSet, ListModelMixin):
"""
Show infomation about migrations related to a destination library.
"""
serializer_class = LibraryMigrationCourseSerializer
pagination_class = None
queryset = ModulestoreMigration.objects.all().select_related('target_collection', 'target', 'task_status')
def get_serializer_context(self):
"""
Add course name list to the serializer context.
We need to display the course names in the migration view, and we get all of
them here to avoid futher queries.
"""
context = super().get_serializer_context()
queryset = self.get_queryset()
course_keys = queryset.values_list('source__key', flat=True)
courses = CourseOverview.get_all_courses(course_keys=course_keys)
context['course_names'] = dict((str(course.id), course.display_name) for course in courses)
return context
def get_queryset(self):
"""
Override the default queryset to filter by the library key and check permissions.
"""
queryset = super().get_queryset()
lib_key_str = self.kwargs['lib_key_str']
try:
library_key = LibraryLocatorV2.from_string(lib_key_str)
except InvalidKeyError as exc:
raise ParseError(detail=f"Malformed library key: {lib_key_str}") from exc
lib_api.require_permission_for_library_key(
library_key,
self.request.user,
lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY
)
queryset = queryset.filter(target__key=library_key, source__key__startswith='course-v1')
return queryset