diff --git a/cms/djangoapps/contentstore/rest_api/serializers/common.py b/cms/djangoapps/contentstore/rest_api/serializers/common.py index 824054330fc2..4eecb704cc40 100644 --- a/cms/djangoapps/contentstore/rest_api/serializers/common.py +++ b/cms/djangoapps/contentstore/rest_api/serializers/common.py @@ -17,6 +17,7 @@ class CourseCommonSerializer(serializers.Serializer): rerun_link = serializers.CharField() run = serializers.CharField() url = serializers.CharField() + translation_info = serializers.CharField() class StrictSerializer(serializers.Serializer): diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_index.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_index.py index 29577d9a75b5..38e7b7f59cc8 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_index.py @@ -32,3 +32,5 @@ class CourseIndexSerializer(serializers.Serializer): rerun_notification_id = serializers.IntegerField() advance_settings_url = serializers.CharField() is_custom_relative_dates_active = serializers.BooleanField() + course_blocks_mapping_url = serializers.CharField() + is_translated_or_base_course = serializers.CharField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_rerun.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_rerun.py index 317468a87a6b..d0719c56e50e 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_rerun.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_rerun.py @@ -4,6 +4,7 @@ from rest_framework import serializers +from django.conf import settings class CourseRerunSerializer(serializers.Serializer): """ Serializer for course rerun """ @@ -13,3 +14,10 @@ class CourseRerunSerializer(serializers.Serializer): number = serializers.CharField() org = serializers.CharField() run = serializers.CharField() + is_translated_rerun = serializers.BooleanField() + language_options = serializers.SerializerMethodField() + + def get_language_options(self, obj): + """Get language options from translated reruns.""" + return settings.ALL_LANGUAGES + diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py index fdc06e9291d0..80d5d1880f55 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py @@ -77,3 +77,9 @@ class StudioHomeSerializer(serializers.Serializer): tech_support_email = serializers.CharField() platform_name = serializers.CharField() user_is_active = serializers.BooleanField() + course_blocks_send_fetch_url = serializers.CharField() + show_meta_api_buttons = serializers.BooleanField() + language_options = serializers.ListSerializer( + child=serializers.ListField(child=serializers.CharField()), + allow_empty=True + ) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py index e71e81ba64f7..5ab5f3f5bd4a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py @@ -32,3 +32,4 @@ class CourseSettingsSerializer(serializers.Serializer): show_min_grade_warning = serializers.BooleanField() sidebar_html_enabled = serializers.BooleanField() upgrade_deadline = serializers.DateTimeField(allow_null=True) + is_destination_course = serializers.BooleanField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py index dffa23349d92..df124882a2fe 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py @@ -11,6 +11,7 @@ ) from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled +from openedx_wikilearn_features.meta_translations.utils import is_destination_block class MessageValidation(serializers.Serializer): """ @@ -90,6 +91,7 @@ class ContainerHandlerSerializer(serializers.Serializer): subsection_location = serializers.CharField(source="subsection.location") course_sequence_ids = serializers.ListField(child=serializers.CharField()) library_content_picker_url = serializers.CharField() + is_translated_or_base_course = serializers.CharField() def get_assets_url(self, obj): """ @@ -130,6 +132,13 @@ class ChildVerticalContainerSerializer(serializers.Serializer): actions = serializers.SerializerMethodField() validation_messages = MessageValidation(many=True) render_error = serializers.CharField() + is_destination_block = serializers.SerializerMethodField() + + def get_is_destination_block(self, obj): + """ + Method to get whether the block is a destination block. + """ + return is_destination_block(obj["xblock"].location) def get_actions(self, obj): # pylint: disable=unused-argument """ diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py index 7216f331d7cb..96fb70cacd84 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py @@ -15,6 +15,7 @@ from ..serializers import CourseDetailsSerializer from ....utils import update_course_details +from openedx_wikilearn_features.meta_translations.utils import is_destination_course @view_auth_classes(is_authenticated=True) class CourseDetailsView(DeveloperErrorViewMixin, APIView): @@ -108,6 +109,11 @@ def get(self, request: Request, course_id: str): # Create a mutable copy and add the field data = dict(serializer.data) data['topic'] = course_block.other_course_settings.get('topic') + data.update( + { + "is_destination_course": is_destination_course(course_key), + } + ) return Response(data) @apidocs.schema( diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py index ffd1fae18a80..18452cb6759a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/settings.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py @@ -100,6 +100,7 @@ def get(self, request: Request, course_id: str): """ # Lazy import to avoid circular import from openedx_wikilearn_features.wikimedia_general.utils import get_topics #pylint: disable=import-outside-toplevel + from openedx_wikilearn_features.meta_translations.utils import is_destination_course #pylint: disable=import-outside-toplevel course_key = CourseKey.from_string(course_id) if not has_studio_read_access(request.user, course_key): self.permission_denied(request) @@ -114,6 +115,7 @@ def get(self, request: Request, course_id: str): 'platform_name': settings.PLATFORM_NAME, 'licensing_enabled': settings.FEATURES.get("LICENSING", False), 'topic_options': get_topics(), + 'is_destination_course': is_destination_course(course_key), }) serializer = CourseSettingsSerializer(settings_context) diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/home.py index 857291fe838b..34fadd14d6f5 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/serializers/home.py +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/home.py @@ -8,6 +8,9 @@ from cms.djangoapps.contentstore.views.course import _get_rerun_link_for_item from openedx.core.lib.api.serializers import CourseKeyField +from openedx_wikilearn_features.meta_translations.models import CourseTranslation +from django.utils.translation import gettext as _ + class UnsucceededCourseSerializerV2(serializers.Serializer): """Serializer for unsucceeded course.""" @@ -35,6 +38,7 @@ class CourseCommonSerializerV2(serializers.Serializer): run = serializers.CharField(source='id.run') url = serializers.SerializerMethodField() is_active = serializers.SerializerMethodField() + translation_info = serializers.SerializerMethodField() def get_lms_link(self, obj): """Get LMS link for course.""" @@ -56,6 +60,10 @@ def get_is_active(self, obj): """Get whether the course is active or not.""" return not obj.has_ended() + def get_translation_info(self, obj): + """Get whether the course is translated or base.""" + return _(CourseTranslation.is_base_or_translated_course(obj.id)) + class CourseHomeTabSerializerV2(serializers.Serializer): """Serializer for course home tab V2 with unsucceeded courses and in process course actions.""" diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/home.py b/cms/djangoapps/contentstore/rest_api/v2/views/home.py index 6d2bd1dcc9be..b33acc3a6622 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/home.py @@ -119,7 +119,8 @@ def get(self, request: Request): "rerun_link": "/course_rerun/course-v1:edX+E2E-101+course", "run": "course", "url": "/course/course-v1:edX+E2E-101+course", - "is_active": true + "is_active": true, + "translation_info": "Translated" }, ], "in_process_course_actions": [], diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index b6cb2af53d56..80a7060f3e84 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -90,6 +90,8 @@ from .toggles import bypass_olx_failure_enabled from .utils import course_import_olx_validation_is_enabled +from openedx_wikilearn_features.meta_translations.utils import rerun_course_translated + User = get_user_model() LOGGER = get_task_logger(__name__) @@ -143,6 +145,11 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i source_course_key = CourseKey.from_string(source_course_key_string) destination_course_key = CourseKey.from_string(destination_course_key_string) try: + # extract translation fields + field_json = fields and json.loads(fields) + is_translated_rerun = field_json and field_json.get('is_translated_rerun', False) + language = field_json and field_json.get('language') + # deserialize the payload fields = deserialize_fields(fields) if fields else None @@ -177,9 +184,9 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i new_restricted_course = clone_instance(restricted_course, {'course_key': destination_course_key}) for country_access_rule in country_access_rules: clone_instance(country_access_rule, {'restricted_course': new_restricted_course}) - org_data = ensure_organization(source_course_key.org) add_organization_course(org_data, destination_course_key) + rerun_course_translated(source_course_key, destination_course_key, user_id, is_translated_rerun, language) return "succeeded" except DuplicateCourseError: @@ -206,6 +213,7 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i def deserialize_fields(json_fields): fields = json.loads(json_fields) + fields.pop("is_translated_rerun", False) for field_name, value in fields.items(): fields[field_name] = getattr(CourseFields, field_name).from_json(value) return fields diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 1ad400f3a5eb..09f7cf3c0cdc 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -117,6 +117,14 @@ from .models import ComponentLink, ContainerLink +# wikimedia imports +from openedx_wikilearn_features.meta_translations.models import CourseTranslation +from openedx_wikilearn_features.meta_translations.utils import ( + get_show_meta_api_buttons, + is_destination_course, + update_course_to_source, +) + IMPORTABLE_FILE_TYPES = ('.tar.gz', '.zip') log = logging.getLogger(__name__) @@ -1332,6 +1340,9 @@ def update_course_details(request, course_key, payload, course_block): """ from .views.entrance_exam import create_entrance_exam, delete_entrance_exam, update_entrance_exam + # check if course was destination course and now it's value is updated + if is_destination_course(course_key) and payload.get('is_destination_course') in ['false', False]: + update_course_to_source(course_key) # if pre-requisite course feature is enabled set pre-requisite course if is_prerequisite_courses_enabled(): @@ -1463,6 +1474,8 @@ def get_course_settings(request, course_key, course_block): 'enable_extended_course_details': enable_extended_course_details, 'upgrade_deadline': upgrade_deadline, 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_block.id), + 'is_destination_course': is_destination_course(course_key), + 'is_mapped_course': bool(CourseTranslation.is_base_or_translated_course(course_key)) } if is_prerequisite_courses_enabled(): courses, in_process_course_actions = get_courses_accessible_to_user(request) @@ -1754,6 +1767,9 @@ def get_home_context(request, no_course=False): 'allowed_organizations_for_libraries': get_allowed_organizations_for_libraries(user), 'can_create_organizations': user_can_create_organizations(user), 'can_access_advanced_settings': auth.has_studio_advanced_settings_access(user), + 'language_options': settings.ALL_LANGUAGES, + 'course_blocks_send_fetch_url': reverse("meta_translations:course_blocks_api_send_fetch"), + 'show_meta_api_buttons': get_show_meta_api_buttons(user), } return home_context @@ -1772,7 +1788,9 @@ def get_course_rerun_context(course_key, course_block, user): 'display_name': course_block.display_name, 'user': user, 'course_creator_status': _get_course_creator_status(user), - 'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False) + 'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False), + 'language_options': settings.ALL_LANGUAGES, + 'is_translated_rerun': CourseTranslation.is_base_or_translated_course(course_key).upper() == "TRANSLATED" } return course_rerun_context @@ -1957,6 +1975,8 @@ def _get_course_index_context(request, course_key, course_block): 'advance_settings_url': reverse_course_url('advanced_settings_handler', course_block.id), 'proctoring_errors': proctoring_errors, 'taxonomy_tags_widget_url': get_taxonomy_tags_widget_url(course_block.id), + 'course_blocks_mapping_url': reverse("meta_translations:course_blocks_mapping"), + 'is_translated_or_base_course': CourseTranslation.is_base_or_translated_course(course_key) } return course_index_context @@ -2084,6 +2104,7 @@ def get_container_handler_context(request, usage_key, course, xblock): # pylint 'is_fullwidth_content': is_library_xblock, 'course_sequence_ids': course_sequence_ids, 'library_content_picker_url': get_library_content_picker_url(course.id), + 'is_translated_or_base_course': CourseTranslation.is_base_or_translated_course(course.id), } return context diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 81ad1eb6ddde..56127ad07dbb 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -122,6 +122,9 @@ ) from .component import ADVANCED_COMPONENT_TYPES +from openedx_wikilearn_features.meta_translations.models import CourseTranslation +from openedx_wikilearn_features.meta_translations.utils import validate_translated_rerun + log = logging.getLogger(__name__) User = get_user_model() @@ -789,7 +792,8 @@ def format_course_for_view(course): 'rerun_link': _get_rerun_link_for_item(course.id), 'org': course.display_org_with_default, 'number': course.display_number_with_default, - 'run': course.location.run + 'run': course.location.run, + 'translation_info': _(CourseTranslation.is_base_or_translated_course(course.id)), } if course.id.deprecated: course_context.update({ @@ -884,10 +888,20 @@ def _create_or_rerun_course(request): {'error': _('Special characters not allowed in organization, course number, and course run.')}, status=400 ) + # meta-translation feature, to identify type of rerun + language = request.json.get('language') + is_translated_rerun = request.json.get('is_translated_rerun') + source_course_key = request.json.get('source_course_key') + error_response = validate_translated_rerun(is_translated_rerun, source_course_key, language) + if error_response: + return error_response fields = {'start': start, 'end': end} if display_name is not None: fields['display_name'] = display_name + if language is not None: + fields['language'] = language + # Set a unique wiki_slug for newly created courses. To maintain active wiki_slugs for # existing xml courses this cannot be changed in CourseBlock. @@ -897,9 +911,9 @@ def _create_or_rerun_course(request): definition_data = {'wiki_slug': wiki_slug} fields.update(definition_data) - source_course_key = request.json.get('source_course_key') if source_course_key: source_course_key = CourseKey.from_string(source_course_key) + fields['is_translated_rerun'] = is_translated_rerun destination_course_key = rerun_course(request.user, source_course_key, org, course, run, fields) return JsonResponse({ 'url': reverse_url('course_handler'), @@ -980,10 +994,13 @@ def create_new_course_in_store(store, user, org, number, run, fields): Create course in store w/ handling instructor enrollment, permissions, and defaulting the wiki slug. Separated out b/c command line course creation uses this as well as the web interface. """ - + # only set course language if not already set for translated courses + if 'language' not in fields: + fields.update({ + 'language': getattr(settings, 'DEFAULT_COURSE_LANGUAGE', 'en'), + }) # Set default language from settings and enable web certs fields.update({ - 'language': getattr(settings, 'DEFAULT_COURSE_LANGUAGE', 'en'), 'cert_html_view_enabled': True, }) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index c98e3d764148..ef9671798607 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -51,6 +51,8 @@ from .access import get_user_role from .session_kv_store import SessionKeyValueStore +from openedx_wikilearn_features.meta_translations.utils import get_translation_context + __all__ = ['preview_handler'] log = logging.getLogger(__name__) @@ -341,7 +343,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): 'is_course': is_course, 'tags_count': tags_count, } - + template_context.update(get_translation_context(xblock)) add_webpack_js_to_fragment(frag, "js/factories/xblock_validation") html = render_to_string('studio_xblock_wrapper.html', template_context) diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 31a17466769d..af2ed88dd678 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -94,6 +94,13 @@ xblock_type_display_name, ) +from openedx_wikilearn_features.meta_translations.models import CourseTranslation +from openedx_wikilearn_features.meta_translations.utils import ( + handle_base_course_block_deletion, + add_translation_metadata, +) +from openedx_wikilearn_features.meta_translations.wiki_components import COMPONENTS_CLASS_MAPPING + log = logging.getLogger(__name__) CREATE_IF_NOT_FOUND = ["course_info"] @@ -200,6 +207,8 @@ def handle_xblock(request, usage_key_string=None): return HttpResponse(status=406) elif request.method == "DELETE": + if CourseTranslation.is_base_course(str(usage_key.course_key)): + handle_base_course_block_deletion(usage_key) _delete_item(usage_key, request.user) return JsonResponse() else: # Since we have a usage_key, we are updating an existing xblock. @@ -282,9 +291,15 @@ def handle_xblock(request, usage_key_string=None): def modify_xblock(usage_key, request): request_data = request.json + xblock = get_xblock(usage_key, request.user) + # For base courses, check if content is updated then set content_updated to True in related CourseBlockData + course_id = xblock.course_id + if CourseTranslation.is_base_course(course_id) and xblock.category in COMPONENTS_CLASS_MAPPING: + COMPONENTS_CLASS_MAPPING[xblock.category]().check_and_sync_base_block_data(xblock, request.json) + return _save_xblock( request.user, - get_xblock(usage_key, request.user), + xblock, data=request_data.get("data"), children_strings=request_data.get("children"), metadata=request_data.get("metadata"), @@ -1111,6 +1126,7 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements "category": xblock.category, "has_children": xblock.has_children, } + add_translation_metadata(xblock_info, xblock) if course is not None and PUBLIC_VIDEO_SHARE.is_enabled(xblock.location.course_key): xblock_info.update( diff --git a/xmodule/modulestore/mixed.py b/xmodule/modulestore/mixed.py index 1d9fd1f84e59..977cbb276388 100644 --- a/xmodule/modulestore/mixed.py +++ b/xmodule/modulestore/mixed.py @@ -23,7 +23,7 @@ from django.utils.timezone import datetime, timezone from xmodule.assetstore import AssetMetadata - +from organizations.models import OrganizationCourse from . import XMODULE_FIELDS_WITH_USAGE_KEYS, ModuleStoreWriteBase from .draft_and_published import ModuleStoreDraftAndPublished from .exceptions import DuplicateCourseError, ItemNotFoundError @@ -452,6 +452,9 @@ def delete_course(self, course_key, user_id): # lint-amnesty, pylint: disable=a See xmodule.modulestore.__init__.ModuleStoreWrite.delete_course """ assert isinstance(course_key, CourseKey) + from openedx_wikilearn_features.meta_translations.models import CourseTranslation + CourseTranslation.delete_base_or_translated_course(course_key) + OrganizationCourse.objects.filter(course_id=str(course_key)).delete() store = self._get_modulestore_for_courselike(course_key) return store.delete_course(course_key, user_id)