Skip to content

Commit 9f48073

Browse files
authored
feat: Preview migration api [FC-0114] (#37818)
Implements a new API to get the summary preview of a migration given a library key and a source key.
1 parent 46272cc commit 9f48073

8 files changed

Lines changed: 922 additions & 4 deletions

File tree

cms/djangoapps/modulestore_migrator/api/read_api.py

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,27 @@
55

66
import typing as t
77
from uuid import UUID
8+
from django.conf import settings
89

910
from opaque_keys.edx.keys import UsageKey
1011
from opaque_keys.edx.locator import (
1112
LibraryLocatorV2, LibraryUsageLocatorV2, LibraryContainerLocator
1213
)
13-
from openedx_learning.api.authoring import get_draft_version
14+
from openedx_learning.api.authoring import get_draft_version, get_all_drafts
1415
from openedx_learning.api.authoring_models import (
1516
PublishableEntityVersion, PublishableEntity, DraftChangeLogRecord
1617
)
18+
from xblock.plugin import PluginMissingError
1719

1820
from openedx.core.djangoapps.content_libraries.api import (
19-
library_component_usage_key, library_container_locator
21+
library_component_usage_key, library_container_locator,
22+
validate_can_add_block_to_library, BlockLimitReachedError,
23+
IncompatibleTypesError, LibraryBlockAlreadyExists,
24+
ContentLibrary
25+
)
26+
from openedx.core.djangoapps.content.search.api import (
27+
fetch_block_types,
28+
get_all_blocks_from_context,
2029
)
2130

2231
from ..data import (
@@ -32,6 +41,7 @@
3241
'get_forwarding_for_blocks',
3342
'get_migrations',
3443
'get_migration_blocks',
44+
'preview_migration',
3545
)
3646

3747

@@ -242,3 +252,120 @@ def _block_migration_success(
242252
target_title=target_title,
243253
target_version_num=target_version_num,
244254
)
255+
256+
257+
def preview_migration(source_key: SourceContextKey, target_key: LibraryLocatorV2):
258+
"""
259+
Returns a summary preview of the migration given a source key and a target key
260+
on this form:
261+
262+
```
263+
{
264+
"state": "partial",
265+
"unsupported_blocks": 4,
266+
"unsupported_percentage": 25,
267+
"blocks_limit": 1000,
268+
"total_blocks": 20,
269+
"total_components": 10,
270+
"sections": 2,
271+
"subsections": 3,
272+
"units": 5,
273+
}
274+
```
275+
276+
List of states:
277+
- 'success': The migration can be carried out in its entirety
278+
- 'partial': The migration will be partial, because there are unsupported blocks.
279+
- 'block_limit_reached': The migration cannot be performed because the block limit per library has been reached.
280+
281+
This runs Meilisiearch queries to speed up the response, as it's a summary/analysis.
282+
The decision has been made not to run a "migration" for each analysis to obtain this summary.
283+
284+
TODO: For now, the repeat_handling_strategy is not taken into account. This can be taken into
285+
account for a more advanced summary.
286+
"""
287+
# Get all containers and components from the source key
288+
blocks = get_all_blocks_from_context(str(source_key), ["block_type", "block_id"])
289+
290+
unsupported_blocks = []
291+
total_blocks = 0
292+
total_components = 0
293+
sections = 0
294+
subsections = 0
295+
units = 0
296+
blocks_limit = settings.MAX_BLOCKS_PER_CONTENT_LIBRARY
297+
298+
# Builds the summary: counts every container and verify if each component can be added to the library
299+
for block in blocks:
300+
block_type = block["block_type"]
301+
block_id = block["block_id"]
302+
total_blocks += 1
303+
if block_type not in ['chapter', 'sequential', 'vertical']:
304+
total_components += 1
305+
try:
306+
validate_can_add_block_to_library(
307+
target_key,
308+
block_type,
309+
block_id,
310+
)
311+
except BlockLimitReachedError:
312+
return {
313+
"state": "block_limit_reached",
314+
"unsupported_blocks": 0,
315+
"unsupported_percentage": 0,
316+
"blocks_limit": blocks_limit,
317+
"total_blocks": 0,
318+
"total_components": 0,
319+
"sections": 0,
320+
"subsections": 0,
321+
"units": 0,
322+
}
323+
except (IncompatibleTypesError, PluginMissingError):
324+
unsupported_blocks.append(block["usage_key"])
325+
except LibraryBlockAlreadyExists:
326+
# Skip this validation, The block may be repeated in the library, but that's not a bad thing.
327+
pass
328+
elif block_type == "chapter":
329+
sections += 1
330+
elif block_type == "sequential":
331+
subsections += 1
332+
elif block_type == "vertical":
333+
units += 1
334+
335+
# Gets the count of children of unsupported blocks
336+
quoted_keys = ','.join(f'"{key}"' for key in unsupported_blocks)
337+
unsupportedBlocksChildren = fetch_block_types(
338+
[
339+
f'context_key = "{source_key}"',
340+
f'breadcrumbs.usage_key IN [{quoted_keys}]'
341+
],
342+
)
343+
# Final unsupported blocks count
344+
# The unsupported children are subtracted from the totals since they have already been counted in the first query.
345+
unsupported_blocks_count = len(unsupported_blocks)
346+
total_blocks -= unsupportedBlocksChildren["estimatedTotalHits"]
347+
total_components -= unsupportedBlocksChildren["estimatedTotalHits"]
348+
unsupported_percentage = (unsupported_blocks_count / total_blocks) * 100
349+
350+
state = "success"
351+
if unsupported_blocks_count:
352+
state = "partial"
353+
354+
# Checks if this migration reaches the block limit
355+
content_library = ContentLibrary.objects.get_by_key(target_key)
356+
assert content_library.learning_package_id is not None
357+
target_item_counts = get_all_drafts(content_library.learning_package_id).count()
358+
if (target_item_counts + total_blocks - unsupported_blocks_count) > blocks_limit:
359+
state = "block_limit_reached"
360+
361+
return {
362+
"state": state,
363+
"unsupported_blocks": unsupported_blocks_count,
364+
"unsupported_percentage": unsupported_percentage,
365+
"blocks_limit": blocks_limit,
366+
"total_blocks": total_blocks,
367+
"total_components": total_components,
368+
"sections": sections,
369+
"subsections": subsections,
370+
"units": units,
371+
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,18 @@ class BlockMigrationInfoSerializer(serializers.Serializer):
289289
source_key = serializers.CharField()
290290
target_key = serializers.CharField(allow_null=True)
291291
unsupported_reason = serializers.CharField(allow_null=True)
292+
293+
294+
class PreviewMigrationSerializer(serializers.Serializer):
295+
"""
296+
Serializer for the preview migration response.
297+
"""
298+
state = serializers.CharField()
299+
unsupported_blocks = serializers.IntegerField()
300+
unsupported_percentage = serializers.FloatField()
301+
blocks_limit = serializers.IntegerField()
302+
total_blocks = serializers.IntegerField()
303+
total_components = serializers.IntegerField()
304+
sections = serializers.IntegerField()
305+
subsections = serializers.IntegerField()
306+
units = serializers.IntegerField()

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
LibraryCourseMigrationViewSet,
1111
MigrationInfoViewSet,
1212
MigrationViewSet,
13+
PreviewMigration,
1314
)
1415

1516
ROUTER = SimpleRouter()
@@ -25,4 +26,5 @@
2526
path('', include(ROUTER.urls)),
2627
path('migration_info/', MigrationInfoViewSet.as_view(), name='migration-info'),
2728
path('migration_blocks/', BlockMigrationInfo.as_view(), name='migration-blocks'),
29+
path('migration_preview/', PreviewMigration.as_view(), name='migration-preview'),
2830
]

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
MigrationInfoResponseSerializer,
4242
ModulestoreMigrationSerializer,
4343
StatusWithModulestoreMigrationsSerializer,
44+
PreviewMigrationSerializer,
4445
)
4546

4647
log = logging.getLogger(__name__)
@@ -668,3 +669,88 @@ def get(self, request: Request):
668669
]
669670
serializer = BlockMigrationInfoSerializer(data, many=True)
670671
return Response(serializer.data)
672+
673+
674+
class PreviewMigration(APIView):
675+
"""
676+
Retrieve the summary preview of the migration given a source key and a target key
677+
678+
It returns the migration block information for each block migrated by a specific task.
679+
680+
API Endpoints
681+
-------------
682+
GET /api/modulestore_migrator/v1/migration_preview/
683+
Retrieve the summary preview of the migration given a source key and a target key
684+
685+
Query parameters:
686+
source_key (str): Source content key
687+
Example: ?source_key=course-v1:UNIX+UX1+2025_T3
688+
target_key (str): target content key
689+
Example: ?target_key=lib:UNIX:CIT1
690+
691+
Example request:
692+
GET /api/modulestore_migrator/v1/migration_blocks/?source_key=course_key&target_key=library_key
693+
694+
Example response:
695+
"""
696+
697+
permission_classes = (IsAuthenticated,)
698+
authentication_classes = (
699+
BearerAuthenticationAllowInactiveUser,
700+
JwtAuthentication,
701+
SessionAuthenticationAllowInactiveUser,
702+
)
703+
704+
@apidocs.schema(
705+
parameters=[
706+
apidocs.string_parameter(
707+
"target_key",
708+
apidocs.ParameterLocation.QUERY,
709+
description="Target key of the migration",
710+
),
711+
apidocs.string_parameter(
712+
"source_key",
713+
apidocs.ParameterLocation.QUERY,
714+
description="Source key of the migration",
715+
),
716+
],
717+
responses={
718+
200: PreviewMigrationSerializer,
719+
400: "Missing required parameter: target_key/source_key",
720+
401: "The requester is not authenticated.",
721+
},
722+
)
723+
def get(self, request: Request):
724+
"""
725+
Handle the migration info `GET` request
726+
"""
727+
target_key: LibraryLocatorV2 | None
728+
if target_key_param := request.query_params.get("target_key"):
729+
try:
730+
target_key = LibraryLocatorV2.from_string(target_key_param)
731+
except InvalidKeyError:
732+
return Response({"error": f"Bad target_key: {target_key_param}"}, status=400)
733+
else:
734+
return Response({"error": "Target key cannot be blank."}, status=400)
735+
source_key: SourceContextKey | None = None
736+
if source_key_param := request.query_params.get("source_key"):
737+
try:
738+
source_key = CourseLocator.from_string(source_key_param)
739+
except InvalidKeyError:
740+
try:
741+
source_key = LibraryLocator.from_string(source_key_param)
742+
except InvalidKeyError:
743+
return Response({"error": f"Bad source: {source_key_param}"}, status=400)
744+
else:
745+
return Response({"error": "Source key cannot be blank."}, status=400)
746+
747+
lib_api.require_permission_for_library_key(
748+
target_key,
749+
request.user,
750+
lib_api.permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
751+
)
752+
result = migrator_api.preview_migration(source_key, target_key)
753+
754+
serializer = PreviewMigrationSerializer(result)
755+
756+
return Response(serializer.data)

0 commit comments

Comments
 (0)