Skip to content

Commit fa97ecc

Browse files
authored
Merge branch 'master' into ttqureshi/enable-html
2 parents fc55b6e + 31e04c9 commit fa97ecc

File tree

23 files changed

+698
-100
lines changed

23 files changed

+698
-100
lines changed

cms/djangoapps/modulestore_migrator/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ class ModulestoreBlockMigrationInline(admin.TabularInline):
178178
"source",
179179
"target",
180180
"change_log_record",
181+
"unsupported_reason",
181182
)
182183
list_display = ("id", *readonly_fields)
183184

cms/djangoapps/modulestore_migrator/api.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
API for migration from modulestore to learning core
33
"""
4+
from uuid import UUID
45
from collections import defaultdict
56
from celery.result import AsyncResult
67
from opaque_keys import InvalidKeyError
@@ -181,5 +182,30 @@ def construct_usage_key(lib_key_str: str, component: Component) -> LibraryUsageL
181182
return {
182183
obj.source.key: construct_usage_key(obj.target.learning_package.key, obj.target.component)
183184
for obj in query_set
184-
if obj.source.key is not None
185+
if obj.source.key is not None and obj.target is not None
185186
}
187+
188+
189+
def get_migration_blocks_info(
190+
target_key: str,
191+
source_key: str | None,
192+
target_collection_key: str | None,
193+
task_uuid: str | None,
194+
is_failed: bool | None,
195+
):
196+
"""
197+
Given the target key, and optional source key, target collection key, task_uuid and is_failed get a dictionary
198+
containing information about migration blocks.
199+
"""
200+
filters: dict[str, str | UUID | bool] = {
201+
'overall_migration__target__key': target_key
202+
}
203+
if source_key:
204+
filters['overall_migration__source__key'] = source_key
205+
if target_collection_key:
206+
filters['overall_migration__target_collection__key'] = target_collection_key
207+
if task_uuid:
208+
filters['overall_migration__task_status__uuid'] = UUID(task_uuid)
209+
if is_failed is not None:
210+
filters['target__isnull'] = is_failed
211+
return ModulestoreBlockMigration.objects.filter(**filters)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 5.2.7 on 2025-11-26 06:35
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
('modulestore_migrator', '0003_modulestoremigration_is_failed'),
10+
('oel_publishing', '0008_alter_draftchangelogrecord_options_and_more'),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name='modulestoreblockmigration',
16+
name='target',
17+
field=models.ForeignKey(
18+
blank=True,
19+
help_text='The target entity of this block migration, set to null if it fails to migrate',
20+
null=True,
21+
on_delete=django.db.models.deletion.CASCADE,
22+
to='oel_publishing.publishableentity',
23+
),
24+
),
25+
migrations.AddField(
26+
model_name='modulestoreblockmigration',
27+
name='unsupported_reason',
28+
field=models.TextField(
29+
blank=True, help_text='Reason if the block is unsupported and target is set to null', null=True
30+
),
31+
),
32+
]

cms/djangoapps/modulestore_migrator/models.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@
66
from django.contrib.auth import get_user_model
77
from django.db import models
88
from django.utils.translation import gettext_lazy as _
9-
from user_tasks.models import UserTaskStatus
10-
119
from model_utils.models import TimeStampedModel
1210
from opaque_keys.edx.django.models import (
1311
LearningContextKeyField,
1412
UsageKeyField,
1513
)
1614
from openedx_learning.api.authoring_models import (
17-
LearningPackage, PublishableEntity, Collection, DraftChangeLog, DraftChangeLogRecord
15+
Collection,
16+
DraftChangeLog,
17+
DraftChangeLogRecord,
18+
LearningPackage,
19+
PublishableEntity,
1820
)
21+
from user_tasks.models import UserTaskStatus
1922

2023
from .data import CompositionLevel, RepeatHandlingStrategy
2124

@@ -210,6 +213,9 @@ class ModulestoreBlockMigration(TimeStampedModel):
210213
target = models.ForeignKey(
211214
PublishableEntity,
212215
on_delete=models.CASCADE,
216+
help_text=_('The target entity of this block migration, set to null if it fails to migrate'),
217+
null=True,
218+
blank=True,
213219
)
214220
change_log_record = models.OneToOneField(
215221
DraftChangeLogRecord,
@@ -218,10 +224,16 @@ class ModulestoreBlockMigration(TimeStampedModel):
218224
null=True,
219225
on_delete=models.SET_NULL,
220226
)
227+
unsupported_reason = models.TextField(
228+
null=True,
229+
blank=True,
230+
help_text=_('Reason if the block is unsupported and target is set to null'),
231+
)
221232

222233
class Meta:
223234
unique_together = [
224235
('overall_migration', 'source'),
236+
# By default defining a unique index on a nullable column will only enforce unicity of non-null values.
225237
('overall_migration', 'target'),
226238
]
227239

cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
from user_tasks.serializers import StatusSerializer
1212

1313
from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy
14-
from cms.djangoapps.modulestore_migrator.models import ModulestoreMigration, ModulestoreSource
14+
from cms.djangoapps.modulestore_migrator.models import (
15+
ModulestoreMigration,
16+
ModulestoreSource,
17+
)
1518

1619

1720
class ModulestoreMigrationSerializer(serializers.Serializer):
@@ -266,3 +269,12 @@ def get_progress(self, obj: ModulestoreMigration):
266269
Return the progress of the migration.
267270
"""
268271
return obj.task_status.completed_steps / obj.task_status.total_steps
272+
273+
274+
class BlockMigrationInfoSerializer(serializers.Serializer):
275+
"""
276+
Serializer for the block migration info.
277+
"""
278+
source_key = serializers.CharField(source="source__key")
279+
target_key = serializers.CharField(source="target__key")
280+
unsupported_reason = serializers.CharField()

cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
"""
22
Course to Library Import API v1 URLs.
33
"""
4-
from django.urls import path, include
4+
from django.urls import include, path
55
from rest_framework.routers import SimpleRouter
6-
from .views import MigrationViewSet, BulkMigrationViewSet, MigrationInfoViewSet, LibraryCourseMigrationViewSet
6+
7+
from .views import (
8+
BlockMigrationInfo,
9+
BulkMigrationViewSet,
10+
LibraryCourseMigrationViewSet,
11+
MigrationInfoViewSet,
12+
MigrationViewSet,
13+
)
714

815
ROUTER = SimpleRouter()
916
ROUTER.register(r'migrations', MigrationViewSet, basename='migrations')
@@ -17,4 +24,5 @@
1724
urlpatterns = [
1825
path('', include(ROUTER.urls)),
1926
path('migration_info/', MigrationInfoViewSet.as_view(), name='migration-info'),
27+
path('migration_blocks/', BlockMigrationInfo.as_view(), name='migration-blocks'),
2028
]

cms/djangoapps/modulestore_migrator/rest_api/v1/views.py

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,37 @@
77
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
88
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
99
from opaque_keys import InvalidKeyError
10+
from opaque_keys.edx.keys import CourseKey
1011
from opaque_keys.edx.locator import LibraryLocatorV2
1112
from rest_framework import status
1213
from rest_framework.exceptions import ParseError
14+
from rest_framework.fields import BooleanField
1315
from rest_framework.mixins import ListModelMixin
1416
from rest_framework.permissions import IsAdminUser, IsAuthenticated
17+
from rest_framework.request import Request
1518
from rest_framework.response import Response
1619
from rest_framework.views import APIView
1720
from rest_framework.viewsets import GenericViewSet
1821
from user_tasks.models import UserTaskStatus
1922
from user_tasks.views import StatusViewSet
20-
from opaque_keys.edx.keys import CourseKey
2123

2224
from cms.djangoapps.modulestore_migrator.api import (
23-
start_migration_to_library,
24-
start_bulk_migration_to_library,
2525
get_all_migrations_info,
26+
get_migration_blocks_info,
27+
start_bulk_migration_to_library,
28+
start_migration_to_library,
2629
)
30+
from common.djangoapps.student.auth import has_studio_write_access
2731
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
2832
from openedx.core.djangoapps.content_libraries import api as lib_api
2933
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
30-
from common.djangoapps.student.auth import has_studio_write_access
3134

3235
from ...models import ModulestoreMigration
3336
from .serializers import (
37+
BlockMigrationInfoSerializer,
3438
BulkModulestoreMigrationSerializer,
35-
MigrationInfoResponseSerializer,
3639
LibraryMigrationCourseSerializer,
40+
MigrationInfoResponseSerializer,
3741
ModulestoreMigrationSerializer,
3842
StatusWithModulestoreMigrationsSerializer,
3943
)
@@ -493,3 +497,105 @@ def get_queryset(self):
493497
queryset = queryset.filter(target__key=library_key, source__key__startswith='course-v1')
494498

495499
return queryset
500+
501+
502+
class BlockMigrationInfo(APIView):
503+
"""
504+
Retrieve migration blocks information given task_uuid, source_key or target_key.
505+
506+
It returns the migration block information for each block migrated by a specific task.
507+
508+
API Endpoints
509+
-------------
510+
GET /api/modulestore_migrator/v1/migration_blocks/
511+
Retrieve migration blocks info for given task_uuid, source_key or target_key.
512+
513+
Query parameters:
514+
task_uuid (str): task uuid
515+
Example: ?task_uuid=dfe72eca-c54f-4b43-b53b-7996031f2102
516+
source_key (str): Source content key
517+
Example: ?source_key=course-v1:UNIX+UX1+2025_T3
518+
target_key (str): target content key
519+
Example: ?target_key=lib:UNIX:CIT1
520+
is_failed (boolean): has the block failed to migrate/import
521+
Example: ?is_failed=true
522+
523+
Example request:
524+
GET /api/modulestore_migrator/v1/migration_blocks/?task_uuid=dfe72eca-c54f-4b43-b53b&is_failed=true
525+
526+
Example response:
527+
"""
528+
529+
permission_classes = (IsAuthenticated,)
530+
authentication_classes = (
531+
BearerAuthenticationAllowInactiveUser,
532+
JwtAuthentication,
533+
SessionAuthenticationAllowInactiveUser,
534+
)
535+
536+
@apidocs.schema(
537+
parameters=[
538+
apidocs.string_parameter(
539+
"target_key",
540+
apidocs.ParameterLocation.QUERY,
541+
description="Filter blocks by target key",
542+
),
543+
apidocs.string_parameter(
544+
"source_key",
545+
apidocs.ParameterLocation.QUERY,
546+
description="Filter blocks by source key",
547+
),
548+
apidocs.string_parameter(
549+
"target_collection_key",
550+
apidocs.ParameterLocation.QUERY,
551+
description="Filter blocks by target_collection_key",
552+
),
553+
apidocs.string_parameter(
554+
"task_uuid",
555+
apidocs.ParameterLocation.QUERY,
556+
description="Filter blocks by task_uuid",
557+
),
558+
apidocs.string_parameter(
559+
"is_failed",
560+
apidocs.ParameterLocation.QUERY,
561+
description="Filter blocks based on its migration status",
562+
),
563+
],
564+
responses={
565+
200: MigrationInfoResponseSerializer,
566+
400: "Missing required parameter: target_key",
567+
401: "The requester is not authenticated.",
568+
},
569+
)
570+
def get(self, request: Request):
571+
"""
572+
Handle the migration info `GET` request
573+
"""
574+
source_key = request.query_params.get("source_key")
575+
target_key = request.query_params.get("target_key")
576+
target_collection_key = request.query_params.get("target_collection_key")
577+
task_uuid = request.query_params.get("task_uuid")
578+
is_failed: str | bool | None = request.query_params.get("is_failed")
579+
if not target_key:
580+
return Response({"error": "Target key cannot be blank."}, status=400)
581+
try:
582+
target_key_parsed = LibraryLocatorV2.from_string(target_key)
583+
except InvalidKeyError as e:
584+
return Response({"error": str(e)}, status=400)
585+
lib_api.require_permission_for_library_key(
586+
target_key_parsed,
587+
request.user,
588+
lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY
589+
)
590+
if is_failed is not None:
591+
is_failed = BooleanField().to_internal_value(is_failed)
592+
593+
data = get_migration_blocks_info(
594+
target_key,
595+
source_key,
596+
target_collection_key,
597+
task_uuid,
598+
is_failed,
599+
).values('source__key', 'target__key', 'unsupported_reason')
600+
serializer = BlockMigrationInfoSerializer(data, many=True)
601+
return Response(serializer.data)

0 commit comments

Comments
 (0)