diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000000..160c48565fce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,153 @@ +# .dockerignore for edx-platform. +# There's a lot here, please try to keep it organized. + +### Files that are not needed in the docker file + +/test_root/ +.git + +### Files private to developers + +# Files that should be git-ignored, but are hand-edited or otherwise valued, +# and so should not be destroyed by "make clean". +# start-noclean +requirements/private.txt +requirements/edx/private.in +requirements/edx/private.txt +lms/envs/private.py +cms/envs/private.py +# end-noclean + +### Python artifacts +**/*.pyc +**/__pycache__ +.venv +venv + +### Editor and IDE artifacts +**/*~ +**/*.swp +**/*.orig +**/nbproject +**/.idea/ +**/.redcar/ +**/codekit-config.json +**/.pycharm_helpers/ +**/_mac/* +**/IntelliLang.xml +**/conda_packages.xml +**/databaseSettings.xml +**/diff.xml +**/debugger.xml +**/editor.xml +**/ide.general.xml +**/inspection/Default.xml +**/other.xml +**/packages.xml +**/web-browsers.xml + +### NFS artifacts +**/.nfs* + +### OS X artifacts +**/*.DS_Store +**/.AppleDouble +**/:2e_* +**/:2e# + +### Internationalization artifacts +**/*.mo +**/*.po +**/*.prob +**/*.dup +!**/django.po +!**/django.mo +!**/djangojs.po +!**/djangojs.mo +conf/locale/en/LC_MESSAGES/*.mo +conf/locale/fake*/LC_MESSAGES/*.po +conf/locale/fake*/LC_MESSAGES/*.mo + +### Testing artifacts +**/.testids/ +**/.noseids +**/nosetests.xml +**/.cache/ +**/.coverage +**/.coverage.* +**/coverage.xml +**/cover/ +**/cover_html/ +**/reports/ +**/jscover.log +**/jscover.log.* +**/.pytest_cache/ +**/pytest_task*.txt +**/.tddium* +common/test/data/test_unicode/static/ +test_root/courses/ +test_root/data/test_bare.git/ +test_root/export_course_repos/ +test_root/paver_logs/ +test_root/uploads/ +**/django-pyfs +**/.tox/ +common/test/data/badges/*.png + +### Installation artifacts +**/*.egg-info +**/.pip_download_cache/ +**/.prereqs_cache +**/.vagrant/ +**/node_modules +**/bin/ + +### Static assets pipeline artifacts +**/*.scssc +lms/static/css/ +lms/static/certificates/css/ +cms/static/css/ +common/static/common/js/vendor/ +common/static/common/css/vendor/ +common/static/bundles +**/webpack-stats.json + +### Styling generated from templates +lms/static/sass/*.css +lms/static/sass/*.css.map +lms/static/certificates/sass/*.css +lms/static/themed_sass/ +cms/static/css/ +cms/static/sass/*.css +cms/static/sass/*.css.map +cms/static/themed_sass/ +themes/**/css + +### Logging artifacts +**/log/ +**/logs +**/chromedriver.log +**/ghostdriver.log + +### Celery artifacts ### +**/celerybeat-schedule + +### Unknown artifacts +**/database.sqlite +**/courseware/static/js/mathjax/* +**/flushdb.sh +**/build +/src/ +\#*\# +**/.env/ +openedx/core/djangoapps/django_comment_common/comment_client/python +**/autodeploy.properties +**/.ws_migrations_complete +**/dist +**/*.bak + +# Visual Studio Code +**/.vscode + +# Locally generated PII reports +**/pii_report diff --git a/.github/workflows/check_python_dependencies.yml b/.github/workflows/check_python_dependencies.yml index f2d73a1f925a..281e26589db2 100644 --- a/.github/workflows/check_python_dependencies.yml +++ b/.github/workflows/check_python_dependencies.yml @@ -31,7 +31,6 @@ jobs: find_python_dependencies \ --req-file requirements/edx/base.txt \ --req-file requirements/edx/testing.txt \ - --ignore https://github.com/edx/codejail-includes \ --ignore https://github.com/edx/edx-name-affirmation \ --ignore https://github.com/mitodl/edx-sga \ --ignore https://github.com/open-craft/xblock-poll diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index f80e304fb791..ff2020afd89f 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -315,7 +315,8 @@ def _insert_static_files_into_downstream_xblock( if hasattr(downstream_xblock, "data") and substitutions: data_with_substitutions = downstream_xblock.data for old_static_ref, new_static_ref in substitutions.items(): - data_with_substitutions = data_with_substitutions.replace( + data_with_substitutions = _replace_strings( + data_with_substitutions, old_static_ref, new_static_ref, ) @@ -325,6 +326,26 @@ def _insert_static_files_into_downstream_xblock( return notices +def _replace_strings(obj: dict | list | str, old_str: str, new_str: str): + """ + Replacing any instances of the given `old_str` string with `new_str` in any strings found in the the given object. + + Returns the updated object. + """ + if isinstance(obj, dict): + for key, value in obj.items(): + obj[key] = _replace_strings(value, old_str, new_str) + + elif isinstance(obj, list): + for index, item in enumerate(obj): + obj[index] = _replace_strings(item, old_str, new_str) + + elif isinstance(obj, str): + return obj.replace(old_str, new_str) + + return obj + + def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> tuple[XBlock | None, StaticFileNotices]: """ Import a block (along with its children and any required static assets) from diff --git a/cms/djangoapps/contentstore/migrations/0011_enable_markdown_editor_flag_by_default.py b/cms/djangoapps/contentstore/migrations/0011_enable_markdown_editor_flag_by_default.py new file mode 100644 index 000000000000..491ae0e4224a --- /dev/null +++ b/cms/djangoapps/contentstore/migrations/0011_enable_markdown_editor_flag_by_default.py @@ -0,0 +1,25 @@ +from django.db import migrations + +from cms.djangoapps.contentstore.toggles import ( + ENABLE_REACT_MARKDOWN_EDITOR +) + + +def create_flag(apps, schema_editor): + Flag = apps.get_model('waffle', 'Flag') + Flag.objects.get_or_create( + name=ENABLE_REACT_MARKDOWN_EDITOR.name, defaults={'everyone': True} + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('contentstore', '0010_container_link_models'), + ('waffle', '0001_initial'), + ] + + operations = [ + # Do not remove the flags for rollback. We don't want to lose originals if + # they already existed, and it won't hurt if they are created. + migrations.RunPython(create_flag, reverse_code=migrations.RunPython.noop), + ] diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index 24dc6748d2c7..2c0a5d42cce9 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -7,7 +7,7 @@ from config_models.models import ConfigurationModel from django.db import models -from django.db.models import Count, F, Q, QuerySet +from django.db.models import Count, F, Q, QuerySet, Max from django.db.models.fields import IntegerField, TextField from django.db.models.functions import Coalesce from django.db.models.lookups import GreaterThan @@ -106,6 +106,34 @@ class EntityLinkBase(models.Model): class Meta: abstract = True + +class ComponentLink(EntityLinkBase): + """ + This represents link between any two publishable entities or link between publishable entity and a course + XBlock. It helps in tracking relationship between XBlocks imported from libraries and used in different courses. + """ + upstream_block = models.ForeignKey( + Component, + on_delete=models.SET_NULL, + related_name="links", + null=True, + blank=True, + ) + upstream_usage_key = UsageKeyField( + max_length=255, + help_text=_( + "Upstream block usage key, this value cannot be null" + " and useful to track upstream library blocks that do not exist yet" + ) + ) + + class Meta: + verbose_name = _("Component Link") + verbose_name_plural = _("Component Links") + + def __str__(self): + return f"ComponentLink<{self.upstream_usage_key}->{self.downstream_usage_key}>" + @property def upstream_version_num(self) -> int | None: """ @@ -132,7 +160,8 @@ def filter_links( ready_to_sync = link_filter.pop('ready_to_sync', None) result = cls.objects.filter(**link_filter).select_related( "upstream_block__publishable_entity__published__version", - "upstream_block__publishable_entity__learning_package" + "upstream_block__publishable_entity__learning_package", + "upstream_block__publishable_entity__published__publish_log_record__publish_log", ).annotate( ready_to_sync=( GreaterThan( @@ -158,13 +187,15 @@ def summarize_by_downstream_context(cls, downstream_context_key: CourseKey) -> Q "upstream_context_title": "CS problems 3", "upstream_context_key": "lib:OpenedX:CSPROB3", "ready_to_sync_count": 11, - "total_count": 14 + "total_count": 14, + "last_published_at": "2025-05-02T20:20:44.989042Z" }, { "upstream_context_title": "CS problems 2", "upstream_context_key": "lib:OpenedX:CSPROB2", "ready_to_sync_count": 15, - "total_count": 24 + "total_count": 24, + "last_published_at": "2025-05-03T21:20:44.989042Z" }, ] """ @@ -173,38 +204,13 @@ def summarize_by_downstream_context(cls, downstream_context_key: CourseKey) -> Q upstream_context_title=F("upstream_block__publishable_entity__learning_package__title"), ).annotate( ready_to_sync_count=Count("id", Q(ready_to_sync=True)), - total_count=Count('id') + total_count=Count("id"), + last_published_at=Max( + "upstream_block__publishable_entity__published__publish_log_record__publish_log__published_at" + ) ) return result - -class ComponentLink(EntityLinkBase): - """ - This represents link between any two publishable entities or link between publishable entity and a course - XBlock. It helps in tracking relationship between XBlocks imported from libraries and used in different courses. - """ - upstream_block = models.ForeignKey( - Component, - on_delete=models.SET_NULL, - related_name="links", - null=True, - blank=True, - ) - upstream_usage_key = UsageKeyField( - max_length=255, - help_text=_( - "Upstream block usage key, this value cannot be null" - " and useful to track upstream library blocks that do not exist yet" - ) - ) - - class Meta: - verbose_name = _("Component Link") - verbose_name_plural = _("Component Links") - - def __str__(self): - return f"ComponentLink<{self.upstream_usage_key}->{self.downstream_usage_key}>" - @classmethod def update_or_create( cls, @@ -232,25 +238,15 @@ def update_or_create( 'version_declined': version_declined, } if upstream_block: - new_values.update( - { - 'upstream_block': upstream_block, - } - ) + new_values['upstream_block'] = upstream_block try: link = cls.objects.get(downstream_usage_key=downstream_usage_key) - # TODO: until we save modified datetime for course xblocks in index, the modified time for links are updated - # everytime a downstream/course block is updated. This allows us to order links[1] based on recently - # modified downstream version. - # pylint: disable=line-too-long - # 1. https://github.com/open-craft/frontend-app-course-authoring/blob/0443d88824095f6f65a3a64b77244af590d4edff/src/course-libraries/ReviewTabContent.tsx#L222-L233 - has_changes = True # change to false once above condition is met. - for key, value in new_values.items(): - prev = getattr(link, key) - # None != None is True, so we need to check for it specially - if prev != value and ~(prev is None and value is None): + has_changes = False + for key, new_value in new_values.items(): + prev_value = getattr(link, key) + if prev_value != new_value: has_changes = True - setattr(link, key, value) + setattr(link, key, new_value) if has_changes: link.updated = created link.save() @@ -290,10 +286,87 @@ class Meta: def __str__(self): return f"ContainerLink<{self.upstream_container_key}->{self.downstream_usage_key}>" + @property + def upstream_version_num(self) -> int | None: + """ + Returns upstream container version number if available. + """ + published_version = get_published_version(self.upstream_container.publishable_entity.id) + return published_version.version_num if published_version else None + + @property + def upstream_context_title(self) -> str: + """ + Returns upstream context title. + """ + return self.upstream_container.publishable_entity.learning_package.title + + @classmethod + def filter_links( + cls, + **link_filter, + ) -> QuerySet["EntityLinkBase"]: + """ + Get all links along with sync flag, upstream context title and version, with optional filtering. + """ + ready_to_sync = link_filter.pop('ready_to_sync', None) + result = cls.objects.filter(**link_filter).select_related( + "upstream_container__publishable_entity__published__version", + "upstream_container__publishable_entity__learning_package" + "upstream_container__publishable_entity__published__publish_log_record__publish_log", + ).annotate( + ready_to_sync=( + GreaterThan( + Coalesce("upstream_container__publishable_entity__published__version__version_num", 0), + Coalesce("version_synced", 0) + ) & GreaterThan( + Coalesce("upstream_container__publishable_entity__published__version__version_num", 0), + Coalesce("version_declined", 0) + ) + ) + ) + if ready_to_sync is not None: + result = result.filter(ready_to_sync=ready_to_sync) + return result + + @classmethod + def summarize_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySet: + """ + Returns a summary of links by upstream context for given downstream_context_key. + Example: + [ + { + "upstream_context_title": "CS problems 3", + "upstream_context_key": "lib:OpenedX:CSPROB3", + "ready_to_sync_count": 11, + "total_count": 14, + "last_published_at": "2025-05-02T20:20:44.989042Z" + }, + { + "upstream_context_title": "CS problems 2", + "upstream_context_key": "lib:OpenedX:CSPROB2", + "ready_to_sync_count": 15, + "total_count": 24, + "last_published_at": "2025-05-03T21:20:44.989042Z" + }, + ] + """ + result = cls.filter_links(downstream_context_key=downstream_context_key).values( + "upstream_context_key", + upstream_context_title=F("upstream_container__publishable_entity__learning_package__title"), + ).annotate( + ready_to_sync_count=Count("id", Q(ready_to_sync=True)), + total_count=Count('id'), + last_published_at=Max( + "upstream_container__publishable_entity__published__publish_log_record__publish_log__published_at" + ) + ) + return result + @classmethod def update_or_create( cls, - upstream_container: Container | None, + upstream_container_id: int | None, /, upstream_container_key: LibraryContainerLocator, upstream_context_key: str, @@ -316,26 +389,16 @@ def update_or_create( 'version_synced': version_synced, 'version_declined': version_declined, } - if upstream_container: - new_values.update( - { - 'upstream_container': upstream_container, - } - ) + if upstream_container_id: + new_values['upstream_container_id'] = upstream_container_id try: link = cls.objects.get(downstream_usage_key=downstream_usage_key) - # TODO: until we save modified datetime for course xblocks in index, the modified time for links are updated - # everytime a downstream/course block is updated. This allows us to order links[1] based on recently - # modified downstream version. - # pylint: disable=line-too-long - # 1. https://github.com/open-craft/frontend-app-course-authoring/blob/0443d88824095f6f65a3a64b77244af590d4edff/src/course-libraries/ReviewTabContent.tsx#L222-L233 - has_changes = True # change to false once above condition is met. - for key, value in new_values.items(): - prev = getattr(link, key) - # None != None is True, so we need to check for it specially - if prev != value and ~(prev is None and value is None): + has_changes = False + for key, new_value in new_values.items(): + prev_value = getattr(link, key) + if prev_value != new_value: has_changes = True - setattr(link, key, value) + setattr(link, key, new_value) if has_changes: link.updated = created link.save() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py index 6c2660428e66..436e9be6fa61 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py @@ -34,6 +34,7 @@ class CourseDetailsSerializer(serializers.Serializer): description = serializers.CharField(allow_blank=True) duration = serializers.CharField(allow_blank=True) effort = serializers.CharField(allow_null=True, allow_blank=True) + # complexity = serializers.CharField(allow_null=True, allow_blank=True, required=False) end_date = serializers.DateTimeField(allow_null=True) enrollment_end = serializers.DateTimeField(allow_null=True) enrollment_start = serializers.DateTimeField(allow_null=True) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py index 3a885f8c96bc..dca8e25cb435 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py @@ -29,6 +29,7 @@ class CourseWaffleFlagsSerializer(serializers.Serializer): use_new_group_configurations_page = serializers.SerializerMethodField() enable_course_optimizer = serializers.SerializerMethodField() use_react_markdown_editor = serializers.SerializerMethodField() + use_video_gallery_flow = serializers.SerializerMethodField() def get_course_key(self): """ @@ -160,3 +161,9 @@ def get_use_react_markdown_editor(self, obj): """ course_key = self.get_course_key() return toggles.use_react_markdown_editor(course_key) + + def get_use_video_gallery_flow(self, obj): + """ + Method to get the use_video_gallery_flow waffle flag + """ + return toggles.use_video_gallery_flow() 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 d5ccf3c6165e..e6ad4b95979f 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py @@ -151,5 +151,13 @@ def put(self, request: Request, course_id: str): except ValidationError as err: return JsonResponseBadRequest({"error": err.message}) + from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + print("REQUEST DATA:", course_id, request.data) + complexity = request.data.get("complexity") + + if complexity: + CourseOverview.objects.filter(id=course_key).update(complexity=complexity) + serializer = CourseDetailsSerializer(updated_data) return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py index 47dacdab27d1..69b2898912aa 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py @@ -61,8 +61,9 @@ def get(self, request, course_id=None): "use_new_course_team_page": true, "use_new_certificates_page": true, "use_new_textbooks_page": true, - "use_new_group_configurations_page": true + "use_new_group_configurations_page": true, "use_react_markdown_editor": true, + "use_video_gallery_flow": true } ``` """ diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py index 1d58f99d386c..ad5696834af2 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py @@ -34,6 +34,7 @@ class CourseWaffleFlagsViewTest(CourseTestCase): 'use_new_updates_page': True, 'use_new_video_uploads_page': False, 'use_react_markdown_editor': False, + 'use_video_gallery_flow': False, } def setUp(self): diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py index 390a32b6ed44..848e9e3a5c7f 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py @@ -28,3 +28,4 @@ class PublishableEntityLinksSummarySerializer(serializers.Serializer): upstream_context_key = serializers.CharField(read_only=True) ready_to_sync_count = serializers.IntegerField(read_only=True) total_count = serializers.IntegerField(read_only=True) + last_published_at = serializers.DateTimeField(read_only=True) diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index 39c2649118f5..8ac82a9452db 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -205,12 +205,14 @@ def get(self, request: _AuthenticatedRequest, course_key_string: str): "upstream_context_key": "lib:OpenedX:CSPROB3", "ready_to_sync_count": 11, "total_count": 14 + "last_published_at": "2025-05-02T20:20:44.989042Z" }, { "upstream_context_title": "CS problems 2", "upstream_context_key": "lib:OpenedX:CSPROB2", "ready_to_sync_count": 15, - "total_count": 24 + "total_count": 24, + "last_published_at": "2025-05-03T21:20:44.989042Z" }, ] """ @@ -267,7 +269,7 @@ def put(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response fetch_customizable_fields_from_block(downstream=downstream, user=request.user) else: assert isinstance(link.upstream_key, LibraryContainerLocator) - fetch_customizable_fields_from_container(downstream=downstream, user=request.user) + fetch_customizable_fields_from_container(downstream=downstream) except BadDownstream as exc: logger.exception( "'%s' is an invalid downstream; refusing to set its upstream to '%s'", 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 426b49dc53e7..950464839c09 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 @@ -110,6 +110,8 @@ def setUp(self): self.learner = UserFactory(username="learner", password="password") self._set_library_block_olx(self.html_lib_id, "Hello world!") self._publish_library_block(self.html_lib_id) + self._publish_library_block(self.video_lib_id) + self._publish_library_block(self.html_lib_id) def _api(self, method, url, data, expect_response): """ @@ -546,6 +548,7 @@ def test_200_summary(self): 'upstream_context_key': self.library_id, 'ready_to_sync_count': 0, 'total_count': 3, + 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), }] self.assertListEqual(data, expected) response = self.call_api(str(self.course.id)) @@ -556,5 +559,6 @@ def test_200_summary(self): 'upstream_context_key': self.library_id, 'ready_to_sync_count': 1, 'total_count': 2, + 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), }] self.assertListEqual(data, expected) diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index b6cb2af53d56..ac421cbb6642 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -130,6 +130,46 @@ def clone_instance(instance, field_values): return instance +def copy_custom_course_overview_fields(source_course_key, destination_course_key): + """ + Copy custom CourseOverview fields from source course to rerun course. + """ + from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + try: + source_overview = CourseOverview.objects.get(id=source_course_key) + + try: + destination_overview = CourseOverview.objects.get(id=destination_course_key) + except CourseOverview.DoesNotExist: + destination_overview = CourseOverview.get_from_id(destination_course_key) + + destination_overview.faculty = source_overview.faculty + destination_overview.directions = source_overview.directions + destination_overview.complexity = source_overview.complexity + + destination_overview.save(update_fields=[ + "faculty", + "directions", + "complexity", + ]) + + LOGGER.info( + "my-log: copied custom overview fields during course rerun: %s -> %s, faculty=%s, directions=%s, complexity=%s", + source_course_key, + destination_course_key, + source_overview.faculty, + source_overview.directions, + source_overview.complexity, + ) + + except Exception: + LOGGER.exception( + "my-log: failed to copy custom overview fields during course rerun: %s -> %s", + source_course_key, + destination_course_key, + ) + @shared_task @set_code_owner_attribute @@ -152,6 +192,8 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i with store.default_store('split'): store.clone_course(source_course_key, destination_course_key, user_id, fields=fields) + copy_custom_course_overview_fields(source_course_key, destination_course_key) + update_unit_discussion_state_from_discussion_blocks(destination_course_key, user_id) # set initial permissions for the user to access the course. diff --git a/cms/djangoapps/contentstore/tests/test_import.py b/cms/djangoapps/contentstore/tests/test_import.py index 73b65197daeb..260636b51baa 100644 --- a/cms/djangoapps/contentstore/tests/test_import.py +++ b/cms/djangoapps/contentstore/tests/test_import.py @@ -146,6 +146,7 @@ def test_asset_import_nostatic(self): import_course_from_xml( module_store, self.user.id, TEST_DATA_DIR, ['toy'], static_content_store=content_store, do_import_static=False, + do_import_python_lib=False, # python_lib.zip is special-cased -- exclude it too create_if_not_present=True, verbose=True ) @@ -153,7 +154,7 @@ def test_asset_import_nostatic(self): # make sure we have NO assets in our contentstore all_assets, count = content_store.get_all_content_for_course(course.id) - self.assertEqual(len(all_assets), 0) + self.assertEqual(all_assets, []) self.assertEqual(count, 0) def test_no_static_link_rewrites_on_import(self): diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index ce244f616ddb..1ad400f3a5eb 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -87,7 +87,7 @@ from common.djangoapps.xblock_django.api import deprecated_xblocks from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from openedx.core import toggles as core_toggles -from openedx.core.djangoapps.content_libraries.api import get_container_from_key +from openedx.core.djangoapps.content_libraries.api import get_container from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled from openedx.core.djangoapps.credit.api import get_credit_requirements, is_credit_course from openedx.core.djangoapps.discussions.config.waffle import ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND @@ -2402,7 +2402,7 @@ def _create_or_update_container_link(course_key: CourseKey, created: datetime | """ upstream_container_key = LibraryContainerLocator.from_string(xblock.upstream) try: - lib_component = get_container_from_key(upstream_container_key) + lib_component = get_container(upstream_container_key).container_pk except ObjectDoesNotExist: log.error(f"Library component not found for {upstream_container_key}") lib_component = None @@ -2429,8 +2429,5 @@ def create_or_update_xblock_upstream_link(xblock, course_key: CourseKey, created _create_or_update_component_link(course_key, created, xblock) except InvalidKeyError: # It is possible that the upstream is a container and UsageKeyV2 parse failed - # Create upstream container link - try: - _create_or_update_container_link(course_key, created, xblock) - except InvalidKeyError: - log.error(f"Invalid key: {xblock.upstream}") + # Create upstream container link and raise InvalidKeyError if xblock.upstream is a valid key. + _create_or_update_container_link(course_key, created, xblock) diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index f03e21342f97..bd0e1c5b1253 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -804,6 +804,12 @@ def setUpClass(cls): super().setUpClass() cls.start_events_isolation() + @classmethod + def tearDownClass(cls): + """ Don't let our event isolation affect other test cases """ + super().tearDownClass() + cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated. + def setUp(self): """Creates the test course structure and a few components to 'duplicate'.""" super().setUp() diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 54beb783f3b0..31a17466769d 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -548,8 +548,7 @@ def sync_library_content(downstream: XBlock, request, store) -> StaticFileNotice notices = [] # Store final children keys to update order of components in unit children = [] - for i in range(len(upstream_children)): - upstream_child = upstream_children[i] + for i, upstream_child in enumerate(upstream_children): assert isinstance(upstream_child, LibraryXBlockMetadata) # for now we only support units if upstream_child.usage_key not in downstream_children_keys: # This upstream_child is new, create it. @@ -574,6 +573,7 @@ def sync_library_content(downstream: XBlock, request, store) -> StaticFileNotice for child in downstream_children: if child.usage_key not in children: # This downstream block was added, or deleted from upstream block. + # NOTE: This will also delete any local additions to a unit in the next upstream sync. store.delete_item(child.usage_key, user_id=request.user.id) downstream.children = children store.update_item(downstream, request.user.id) diff --git a/cms/envs/common.py b/cms/envs/common.py index 787db40bd308..5fb375d6a407 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1608,6 +1608,9 @@ # remember to check that you are not creating new # RemovedInDjango19Warnings in the test logs. INSTALLED_APPS = [ + 'lms.djangoapps.news', + 'lms.djangoapps.univerapi', + # Standard apps 'django.contrib.auth', 'django.contrib.contenttypes', @@ -2701,17 +2704,17 @@ DISABLE_DEPRECATED_SIGNUP_URL = False ##### LOGISTRATION RATE LIMIT SETTINGS ##### -LOGISTRATION_RATELIMIT_RATE = '100/5m' -LOGISTRATION_PER_EMAIL_RATELIMIT_RATE = '30/5m' -LOGISTRATION_API_RATELIMIT = '20/m' -LOGIN_AND_REGISTER_FORM_RATELIMIT = '100/5m' +LOGISTRATION_RATELIMIT_RATE = '3000/5m' +LOGISTRATION_PER_EMAIL_RATELIMIT_RATE = '300/5m' +LOGISTRATION_API_RATELIMIT = '1000/m' +LOGIN_AND_REGISTER_FORM_RATELIMIT = '3000/5m' RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT = '30/7d' RESET_PASSWORD_API_RATELIMIT = '30/7d' ##### REGISTRATION RATE LIMIT SETTINGS ##### -REGISTRATION_VALIDATION_RATELIMIT = '30/7d' -REGISTRATION_RATELIMIT = '60/7d' -OPTIONAL_FIELD_API_RATELIMIT = '10/h' +REGISTRATION_VALIDATION_RATELIMIT = '3000/7d' +REGISTRATION_RATELIMIT = '6000/7d' +OPTIONAL_FIELD_API_RATELIMIT = '100/h' ##### PASSWORD RESET RATE LIMIT SETTINGS ##### PASSWORD_RESET_IP_RATE = '1/m' @@ -2915,10 +2918,13 @@ def _should_send_learning_badge_events(settings): 'openassessment', 'conditional', 'done', + 'edx_sga', 'freetextresponse', 'google-calendar', 'google-document', 'invideoquiz', + 'lti', + 'lti_consumer', 'pdf', 'poll', 'survey', diff --git a/cms/lib/xblock/upstream_sync_container.py b/cms/lib/xblock/upstream_sync_container.py index 44e3b429ec05..4e8302323808 100644 --- a/cms/lib/xblock/upstream_sync_container.py +++ b/cms/lib/xblock/upstream_sync_container.py @@ -45,7 +45,7 @@ def sync_from_upstream_container( user, permission=lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, ) - upstream_meta = lib_api.get_container(link.upstream_key, user) + upstream_meta = lib_api.get_container(link.upstream_key) upstream_children = lib_api.get_container_children(link.upstream_key, published=True) _update_customizable_fields(upstream=upstream_meta, downstream=downstream, only_fetch=False) _update_non_customizable_fields(upstream=upstream_meta, downstream=downstream) @@ -54,7 +54,7 @@ def sync_from_upstream_container( return upstream_children -def fetch_customizable_fields_from_container(*, downstream: XBlock, user: User) -> None: +def fetch_customizable_fields_from_container(*, downstream: XBlock) -> None: """ Fetch upstream-defined value of customizable fields and save them on the downstream. @@ -62,7 +62,7 @@ def fetch_customizable_fields_from_container(*, downstream: XBlock, user: User) Basically, this sets the value of "upstream_display_name" on the downstream block. """ - upstream = lib_api.get_container(LibraryContainerLocator.from_string(downstream.upstream), user) + upstream = lib_api.get_container(LibraryContainerLocator.from_string(downstream.upstream)) _update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=True) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 5f970a89d592..8b6bca95f315 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -73,6 +73,18 @@ function( // nav - dropdown related $body.click(function() { + // Reset iframe height to default when the XBlock action dropdown is closed + if ($('.nav-dd .nav-item .wrapper-nav-sub.is-shown').length && window.self !== window.top) { + try { + window.parent.postMessage({ + type: 'toggleCourseXBlockDropdown', + message: 'Adjust the height of the dropdown menu', + payload: { courseXBlockDropdownHeight: 0 } + }, document.referrer); + } catch (e) { + console.error('Failed to post message:', e); + } + } $('.nav-dd .nav-item .wrapper-nav-sub').removeClass('is-shown'); $('.nav-dd .nav-item .title').removeClass('is-selected'); $('.custom-dropdown .dropdown-options').hide(); diff --git a/cms/static/js/views/metadata.js b/cms/static/js/views/metadata.js index 1dda68b30947..07aa1bed971a 100644 --- a/cms/static/js/views/metadata.js +++ b/cms/static/js/views/metadata.js @@ -60,13 +60,18 @@ define( /** * Returns just the modified metadata values, in the format used to persist to the server. + * Set `replaceNullWithDefault` to true to replace null values with the default values */ - getModifiedMetadataValues: function() { + getModifiedMetadataValues: function(replaceNullWithDefault = false) { var modified_values = {}; this.collection.each( function(model) { if (model.isModified()) { - modified_values[model.getFieldName()] = model.getValue(); + let value = model.getValue(); + if (replaceNullWithDefault && value === null) { + value = model.getDisplayValue(); + } + modified_values[model.getFieldName()] = value } } ); diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index b483b98f1174..3f02ae600a0b 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -64,6 +64,8 @@ function($, _, Backbone, gettext, BasePage, this.isLibraryPage = this.model.attributes.category === 'library'; this.isLibraryContentPage = this.model.attributes.category === 'library_content'; this.isSplitTestContentPage = this.model.attributes.category === 'split_test'; + this.isVerticalContentPage = this.model.attributes.category === 'vertical'; + this.nameEditor = new XBlockStringFieldEditor({ el: this.$('.wrapper-xblock-field'), model: this.model @@ -160,6 +162,9 @@ function($, _, Backbone, gettext, BasePage, case 'completeManageXBlockAccess': this.refreshXBlock(xblockElement, false); break; + case 'completeXBlockDuplicating': + this.refreshXBlock(xblockElement, true, true); + break; case 'completeXBlockMoving': xblockWrapper.hide(); break; @@ -678,33 +683,35 @@ function($, _, Backbone, gettext, BasePage, // Calculate the viewport height and the dropdown menu height. // Check if the dropdown would overflow beyond the iframe height based on the user's click position. // If the dropdown overflows, adjust its position to display above the click point. - const courseUnitXBlockIframeHeight = window.innerHeight; - const courseXBlockDropdownHeight = subMenu.offsetHeight; - const clickYPosition = event.clientY; - - if (courseUnitXBlockIframeHeight < courseXBlockDropdownHeight) { - // If the dropdown menu is taller than the iframe, adjust the height of the dropdown menu. + const iframeHeight = window.innerHeight; + const dropdownHeight = subMenu.offsetHeight; + const offsetBuffer = 10; + + const targetRect = event.target.getBoundingClientRect(); + const targetBottom = targetRect.bottom; + const targetTop = targetRect.top; + + // Calculate total space needed below the target to fit dropdown + const dropdownBottom = targetBottom + dropdownHeight + offsetBuffer; + + const dropdownFitsBelow = dropdownBottom <= iframeHeight; + const dropdownFitsAbove = dropdownHeight + offsetBuffer < targetTop; + + if (!dropdownFitsBelow) { + if (dropdownFitsAbove && this.options.isIframeEmbed) { + // Display the dropdown above the button + subMenu.style.top = `-${dropdownHeight}px`; + } else { + // Request parent to expand iframe height to fit dropdown + const requiredExtraHeight = dropdownBottom - iframeHeight; this.postMessageToParent({ - type: 'toggleCourseXBlockDropdown', - message: 'Adjust the height of the dropdown menu', - payload: { courseXBlockDropdownHeight }, + type: 'toggleCourseXBlockDropdown', + message: 'Expand iframe to fit dropdown', + payload: { + courseXBlockDropdownHeight: requiredExtraHeight, + }, }); - } else if ((courseXBlockDropdownHeight + clickYPosition) > courseUnitXBlockIframeHeight) { - if (courseXBlockDropdownHeight > courseUnitXBlockIframeHeight / 2) { - // If the dropdown menu is taller than half the iframe, send a message to adjust its height. - this.postMessageToParent({ - type: 'toggleCourseXBlockDropdown', - message: 'Adjust the height of the dropdown menu', - payload: { - courseXBlockDropdownHeight: courseXBlockDropdownHeight / 2, - }, - }); - } else { - // Move the dropdown menu upward to prevent it from overflowing out of the viewport. - if (this.options.isIframeEmbed) { - subMenu.style.top = `-${courseXBlockDropdownHeight}px`; - } - } + } } // if propagation is not stopped, the event will bubble up to the @@ -1151,15 +1158,7 @@ function($, _, Backbone, gettext, BasePage, || (useNewVideoEditor === 'True' && blockType.includes('video')) || (useNewProblemEditor === 'True' && blockType.includes('problem'))) ){ - var destinationUrl; - if (useVideoGalleryFlow === 'True' && blockType.includes('video')) { - destinationUrl = this.$('.xblock-header-primary').attr("authoring_MFE_base_url") + '/course-videos/' + encodeURI(data.locator); - } - else { - destinationUrl = this.$('.xblock-header-primary').attr("authoring_MFE_base_url") + '/' + blockType[1] + '/' + encodeURI(data.locator); - } - - if (this.options.isIframeEmbed && this.isSplitTestContentPage) { + if (this.options.isIframeEmbed && (this.isSplitTestContentPage || this.isVerticalContentPage)) { return this.postMessageToParent({ type: 'handleRedirectToXBlockEditPage', message: 'Redirect to xBlock edit page', @@ -1169,7 +1168,13 @@ function($, _, Backbone, gettext, BasePage, }, }); } - + var destinationUrl; + if (useVideoGalleryFlow === 'True' && blockType.includes('video')) { + destinationUrl = this.$('.xblock-header-primary').attr("authoring_MFE_base_url") + '/course-videos/' + encodeURI(data.locator); + } + else { + destinationUrl = this.$('.xblock-header-primary').attr("authoring_MFE_base_url") + '/' + blockType[1] + '/' + encodeURI(data.locator); + } window.location.href = destinationUrl; return; } diff --git a/cms/static/js/views/xblock_editor.js b/cms/static/js/views/xblock_editor.js index 52d08dc76fb4..b6bae05bb9dc 100644 --- a/cms/static/js/views/xblock_editor.js +++ b/cms/static/js/views/xblock_editor.js @@ -125,10 +125,11 @@ function($, _, gettext, BaseView, XBlockView, MetadataView, MetadataCollection) /** * Returns the metadata that has changed in the editor. This is a combination of the metadata * modified in the "Settings" editor, as well as any custom metadata provided by the component. + * Set `replaceNullWithDefault` to true to replace null values with the default values. */ - getChangedMetadata: function() { + getChangedMetadata: function(replaceNullWithDefault = false) { var metadataEditor = this.getMetadataEditor(); - return _.extend(metadataEditor.getModifiedMetadataValues(), this.getCustomMetadata()); + return _.extend(metadataEditor.getModifiedMetadataValues(replaceNullWithDefault), this.getCustomMetadata()); }, /** diff --git a/cms/static/sass/course-unit-mfe-iframe-bundle.scss b/cms/static/sass/course-unit-mfe-iframe-bundle.scss index 7a75cc53da84..4100310f406b 100644 --- a/cms/static/sass/course-unit-mfe-iframe-bundle.scss +++ b/cms/static/sass/course-unit-mfe-iframe-bundle.scss @@ -8,7 +8,7 @@ html { body { - min-width: 800px; + min-width: 560px; background: transparent; &.openassessment_full_height.view-container { overflow-y: hidden; @@ -39,11 +39,19 @@ body, padding: ($baseline * 1.2) ($baseline * 1.2) ($baseline / 1.67); border-bottom: none; - .header-details .xblock-display-name { - font-size: 22px; - line-height: 28px; - font-weight: 700; - color: $black; + .header-details { + .xblock-display-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .xblock-display-name { + font-size: 22px; + line-height: 28px; + font-weight: 700; + color: $black; + } } } @@ -345,7 +353,6 @@ body, } .tip.setting-help { - color: $border-color; font-size: 14px; line-height: $base-font-size; } @@ -452,6 +459,11 @@ body, .modal-lg.modal-window.confirm.openassessment_modal_window { height: 635px; + max-height: 100vh; + + .edit-xblock-modal .modal-content { + max-height: 100%; + } } // Additions for the xblock editor on the Library Authoring @@ -672,11 +684,21 @@ body [class*="view-"] .openassessment_editor_buttons.xblock-actions { max-width: 1200px; } - .modal-lg.modal-editor .modal-header .editor-modes .action-item { - .editor-button, - .settings-button { - @extend %light-button; - } + .modal-lg.modal-editor { + .modal-header .editor-modes .action-item { + .editor-button, + .settings-button { + @extend %light-button; + } + } + + .edit-xblock-modal .modal-content { + max-height: calc(100vh - 144px); + + .editor-with-buttons.wrapper-comp-settings .list-input.settings-list { + max-height: calc(100vh - 205px); + } + } } .wrapper.wrapper-modal-window .modal-window .modal-actions .action-primary { diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index f349ea978686..f6022c6ac0e5 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -211,13 +211,6 @@ % endif - % if not show_inline: -
  • - - ${_("Details")} - -
  • - % endif % endif % endif diff --git a/common/templates/xblock_v2/xblock_iframe.html b/common/templates/xblock_v2/xblock_iframe.html index 39eb044b183e..cd3096aa4626 100644 --- a/common/templates/xblock_v2/xblock_iframe.html +++ b/common/templates/xblock_v2/xblock_iframe.html @@ -40,6 +40,7 @@ // The minimal RequireJS configuration required for common LMS and CMS building XBlock types to work: require = require || RequireJS.require; define = define || RequireJS.define; + baseUrl = ""; (function (require, define) { if ('{{ view_name | safe }}' === 'studio_view') { // Call `require-config.js` of the CMS @@ -47,9 +48,10 @@ script.type = 'text/javascript'; script.src = "{{ cms_root_url }}/static/studio/cms/js/require-config.js"; document.head.appendChild(script); + baseUrl = "{{ cms_root_url }}/static/studio"; require.config({ - baseUrl: "{{ cms_root_url }}/static/studio", + baseUrl, paths: { accessibility: 'js/src/accessibility_tools', draggabilly: 'js/vendor/draggabilly', @@ -64,8 +66,9 @@ "{{ lms_root_url }}/static/dist{{ oa_manifest.oa_editor_textarea_js }}", ]); } else { + baseUrl = "{{ lms_root_url }}/static/"; require.config({ - baseUrl: "{{ lms_root_url }}/static/", + baseUrl, paths: { accessibility: 'js/src/accessibility_tools', draggabilly: 'js/vendor/draggabilly', @@ -273,8 +276,6 @@ // Check if the XBlock has an initialization function: const initFunctionName = element.getAttribute('data-init'); if (initFunctionName !== null) { - // Since this block has an init function, it may need to call handlers: - element[HANDLER_URL] = HANDLER_URL_MAP[usageId]; // Now proceed with initializing the block's JavaScript: const InitFunction = (window)[initFunctionName]; // Does the XBlock HTML contain arguments to pass to the InitFunction? @@ -293,13 +294,12 @@ // to pass 'element' as a jQuery-wrapped DOM element, whereas the LMS // runtime used to pass 'element' as the pure DOM node. In order not to // break backwards compatibility, we would need to maintain that. - // However, this is currently disabled as it causes issues (need to - // modify the runtime methods like handlerUrl too), and we decided not - // to maintain support for legacy studio_view in this runtime. - // const isStudioView = element.className.indexOf('studio_view') !== -1; - // const passElement = isStudioView && (window as any).$ ? (window as any).$(element) : element; - const blockJS = new InitFunction(runtime, element, data) || {}; - blockJS.element = element; + const isStudioView = element.className.indexOf('studio_view') !== -1; + const passElement = isStudioView && window.$ ? window.$(element) : element; + // Since this block has an init function, it may need to call handlers: + passElement[HANDLER_URL] = HANDLER_URL_MAP[usageId]; + const blockJS = new InitFunction(runtime, passElement, data) || {}; + blockJS.element = passElement; if (['MetadataOnlyEditingDescriptor', 'SequenceDescriptor'].includes(data['xmodule-type'])) { // The xmodule type `MetadataOnlyEditingDescriptor` and `SequenceDescriptor` renders a `
    ` with @@ -308,7 +308,7 @@ // editor using the metadata. require(['{{ cms_root_url }}/static/studio/js/views/xblock_editor.js'], function(XBlockEditorView) { var editorView = new XBlockEditorView({ - el: element, + el: passElement, xblock: blockJS, }); // To render block using metadata @@ -327,35 +327,28 @@
    `; - element.innerHTML += xblockActions; - - const views = editorView.getMetadataEditor().views; - Object.values(views).forEach(view => { - const uniqueId = view.uniqueId; - const input = element.querySelector(`#${uniqueId}`); - if (input) { - input.addEventListener("input", function(event) { - view.model.setValue(event.target.value); - }); - } - }); - + // Check if passElement is a jQuery-wrapped dom element + if (passElement.jquery) { + passElement.append(xblockActions); + } else { + passElement.innerHTML += xblockActions; + } // Adding cancel functionality - $('.cancel-button', element).bind('click', function() { + $('.cancel-button', passElement).bind('click', function() { runtime.notify('cancel', {}); event.preventDefault(); }); // Adding save functionality - $('.save-button', element).bind('click', function() { + $('.save-button', passElement).bind('click', function() { //event.preventDefault(); - var error_message_div = $('.xblock-editor-error-message', element); - const modifiedData = editorView.getChangedMetadata(); + var error_message_div = $('.xblock-editor-error-message', passElement); + const modifiedData = editorView.getChangedMetadata(true); error_message_div.html(); error_message_div.css('display', 'none'); - var handlerUrl = runtime.handlerUrl(element, 'studio_submit'); + var handlerUrl = runtime.handlerUrl(passElement, 'studio_submit'); runtime.notify('save', {state: 'start', message: gettext("Saving")}); @@ -385,7 +378,6 @@ if ('{{ view_name | safe }}' === 'studio_view') { // Used when rendering the `studio_view`, in order to avoid open a new tab on click cancel or save const selectors = [ - '.cancel-button', '.save-button', '.action-cancel', '.action-save', @@ -401,6 +393,24 @@ }); } } + + // This button is used in `StudioEditableXBlockMixin` from the `Xblock` app + // That app adds a listener that removes any TinyMCE editors on click cancel. + // ref: https://github.com/openedx/XBlock/blob/86eee4b05dffa42b009fab2a9050b73766131b9d/xblock/utils/public/studio_edit.js#L169 + // + // Here that is an issue because we show a confirmation modal when clicking cancel, + // if the user stays to edit all TinyMCE editors are no longer there. + // + // We uncouple the listener to avoid remove the TinyMCE editors + const extraCancelSelector = '.cancel-button'; + const elements = $(extraCancelSelector).first(); + if (elements.length) { + elements.first().unbind("click"); + elements.on('click', function() { + event.preventDefault(); + runtime.notify('cancel', {}); + }); + } } } @@ -455,9 +465,6 @@ // it will report the height of its contents to the parent window when the // document loads, window resizes, or DOM mutates. if (window !== window.parent) { - var lastHeight = window.parent[0].offsetHeight; - var lastWidth = window.parent[0].offsetWidth; - function dispatchResizeMessage(event) { // Note: event is actually an Array of MutationRecord objects when fired from the MutationObserver var newHeight = rootNode.scrollHeight; @@ -472,10 +479,6 @@ } }, document.referrer ); - - lastHeight = newHeight; - lastWidth = newWidth; - // Within the authoring microfrontend the iframe resizes to match the // height of this document and it should never scroll. It does scroll // ocassionally when javascript is used to focus elements on the page diff --git a/common/test/data/toy/static/python_lib.zip b/common/test/data/toy/static/python_lib.zip new file mode 100644 index 000000000000..5854e82b68d9 Binary files /dev/null and b/common/test/data/toy/static/python_lib.zip differ diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 3b6cc75e4890..1a2651166508 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -121,7 +121,8 @@ def _update_certificate_context(context, course, course_overview, user_certifica else: date = display_date_for_certificate(course, user_certificate) # Translators: The format of the date includes the full name of the month - context['certificate_date_issued'] = strftime_localized(date, settings.CERTIFICATE_DATE_FORMAT) + # context['certificate_date_issued'] = strftime_localized(date, settings.CERTIFICATE_DATE_FORMAT) + context['certificate_date_issued'] = date.strftime("%d.%m.%Y") # Translators: This text represents the verification of the certificate context['document_meta_description'] = _('This is a valid {platform_name} certificate for {user_name}, ' diff --git a/lms/djangoapps/course_api/serializers.py b/lms/djangoapps/course_api/serializers.py index 5fc711376784..bd4e981d2e9d 100644 --- a/lms/djangoapps/course_api/serializers.py +++ b/lms/djangoapps/course_api/serializers.py @@ -17,7 +17,6 @@ from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.lib.api.fields import AbsoluteURLField - class _MediaSerializer(serializers.Serializer): # pylint: disable=abstract-method """ Nested serializer to represent a media object. @@ -100,6 +99,7 @@ class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-meth blocks_url = serializers.SerializerMethodField() effort = serializers.CharField() + # complexity = serializers.SerializerMethodField() end = serializers.DateTimeField() enrollment_start = serializers.DateTimeField() enrollment_end = serializers.DateTimeField() @@ -120,6 +120,9 @@ class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-meth # 'course_id' is a deprecated field, please use 'id' instead. course_id = serializers.CharField(source='id', read_only=True) + def get_complexity(self, obj): + return CourseDetails.fetch_about_attribute(obj.id, 'complexity') + def get_hidden(self, course_overview): """ Get the representation for SerializerMethodField `hidden` diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 3e7a3fae8ae4..c47ab97ea249 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -285,37 +285,22 @@ def user_groups(user): return group_names + @ensure_csrf_cookie @cache_if_anonymous() def courses(request): """ - Render "find courses" page. The course selection work is done in courseware.courses. + Render "find courses" page. Only show courses with catalog_visibility = both. """ - courses_list = [] - course_discovery_meanings = getattr(settings, 'COURSE_DISCOVERY_MEANINGS', {}) - set_default_filter = ENABLE_COURSE_DISCOVERY_DEFAULT_LANGUAGE_FILTER.is_enabled() - if not settings.FEATURES.get('ENABLE_COURSE_DISCOVERY'): - courses_list = get_courses( - request.user, - filter_={"catalog_visibility": CATALOG_VISIBILITY_CATALOG_AND_ABOUT}, - ) - - if configuration_helpers.get_value("ENABLE_COURSE_SORTING_BY_START_DATE", - settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"]): - courses_list = sort_by_start_date(courses_list) - else: - courses_list = sort_by_announcement(courses_list) - - # Add marketable programs to the context. - programs_list = get_programs_with_type(request.site, include_hidden=False) + courses_list = get_courses( + request.user, + filter_={"catalog_visibility": CATALOG_VISIBILITY_CATALOG_AND_ABOUT}, + ) return render_to_response( - "courseware/courses.html", + "courseware/courses_summer.html", { 'courses': courses_list, - 'course_discovery_meanings': course_discovery_meanings, - 'set_default_filter': set_default_filter, - 'programs_list': programs_list, } ) @@ -866,13 +851,17 @@ def course_about(request, course_id): # pylint: disable=too-many-statements # Overview overview = CourseOverview.get_from_id(course.id) - + same_name_courses = CourseOverview.objects.filter( + display_name=overview.display_name + ).order_by('start') sidebar_html_enabled = ENABLE_COURSE_ABOUT_SIDEBAR_HTML.is_enabled() allow_anonymous = check_public_access(course, [COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE]) + complexity = overview.complexity context = { 'course': course, + 'complexity': complexity, 'course_details': course_details, 'staff_access': staff_access, 'studio_url': studio_url, @@ -897,6 +886,7 @@ def course_about(request, course_id): # pylint: disable=too-many-statements 'course_image_urls': overview.image_urls, 'sidebar_html_enabled': sidebar_html_enabled, 'allow_anonymous': allow_anonymous, + 'same_name_courses': same_name_courses, } course_about_template = 'courseware/course_about.html' @@ -985,7 +975,10 @@ def _progress(request, course_key, student_id): student_id = int(student_id) # Check for ValueError if 'student_id' cannot be converted to integer. except ValueError: - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + try: + user_by_username = User.objects.get(username=student_id) + except User.DoesNotExist: + raise Http404 course = get_course_with_access(request.user, 'load', course_key) @@ -1007,9 +1000,12 @@ def _progress(request, course_key, student_id): if not has_access_on_students_profiles: raise Http404 try: - student = User.objects.get(id=student_id) + if user_by_username: + student = user_by_username + else: + student = User.objects.get(id=student_id) except User.DoesNotExist: - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + raise Http404 # lint-amnesty, pylint: disable=raise-missing-from # NOTE: To make sure impersonation by instructor works, use # student instead of request.user in the rest of the function. diff --git a/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py index 17705f835e9d..f81947ce1e78 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -29,6 +29,7 @@ from lms.lib.utils import get_parent_unit from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.djangolib.markup import Text +from openedx.features.course_experience.url_helpers import get_courseware_url from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order @@ -256,16 +257,8 @@ def get_block_context(course, block): course = block.get_parent() block_dict['index'] = get_index(block_dict['location'], course.children) elif block.category == 'vertical': - section = block.get_parent() - chapter = section.get_parent() - # Position starts from 1, that's why we add 1. - position = get_index(str(block.location), section.children) + 1 - block_dict['url'] = reverse('courseware_position', kwargs={ - 'course_id': str(course.id), - 'chapter': chapter.url_name, - 'section': section.url_name, - 'position': position, - }) + # Use the MFE-aware URL generator instead of always using the legacy URL format + block_dict['url'] = get_courseware_url(block.location) if block.category in ('chapter', 'sequential'): block_dict['children'] = [str(child) for child in block.children] diff --git a/lms/djangoapps/grades/rest_api/serializers.py b/lms/djangoapps/grades/rest_api/serializers.py index b5c757f31bc6..c3969a947ff4 100644 --- a/lms/djangoapps/grades/rest_api/serializers.py +++ b/lms/djangoapps/grades/rest_api/serializers.py @@ -57,7 +57,7 @@ class StudentGradebookEntrySerializer(serializers.Serializer): external_user_key = serializers.CharField(required=False) percent = serializers.FloatField() section_breakdown = SectionBreakdownSerializer(many=True) - + profile_name = serializers.CharField(required=False, allow_null=True) class SubsectionGradeOverrideSerializer(serializers.Serializer): """ diff --git a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py index c295563da565..14c5e38c6313 100644 --- a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py +++ b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py @@ -507,6 +507,8 @@ def _gradebook_entry(self, user, course, graded_subsections, course_grade): kwargs=dict(course_id=str(course.id), student_id=user.id) ) user_entry['user_id'] = user.id + profile = getattr(user, 'profile', None) + user_entry['profile_name'] = getattr(profile, 'name', None) def is_masters_student(): # If this is a multiple-user lookup (didn't use the username param) we insert @@ -654,7 +656,7 @@ def get(self, request, course_key): # lint-amnesty, pylint: disable=too-many-st # TODO: In django 3.0+, we can directly filter on this 'exists' rather than annotating q_objects.append(Q(has_excluded_role=False)) entries = [] - related_models = ['user'] + related_models = ['user', 'user__profile'] users = self._paginate_users(course_key, q_objects, related_models, annotations=annotations) users_counts = self._get_users_counts(course_key, q_objects, annotations=annotations) diff --git a/lms/djangoapps/instructor_task/tasks_helper/grades.py b/lms/djangoapps/instructor_task/tasks_helper/grades.py index 5358af370897..b9cb4ca19454 100644 --- a/lms/djangoapps/instructor_task/tasks_helper/grades.py +++ b/lms/djangoapps/instructor_task/tasks_helper/grades.py @@ -462,20 +462,26 @@ def get_enrolled_learners_for_course(course_id, verified_only=False): } if verified_only: filter_kwargs['courseenrollment__mode'] = CourseMode.VERIFIED - - user_ids_list = get_user_model().objects.filter(**filter_kwargs).values_list('id', flat=True).order_by('id') - user_chunks = grouper(user_ids_list) - for user_ids in user_chunks: - user_ids = [user_id for user_id in user_ids if user_id is not None] - min_id = min(user_ids) - max_id = max(user_ids) - users = get_user_model().objects.filter( - id__gte=min_id, - id__lte=max_id, - **filter_kwargs - ).select_related('profile') - - yield users + # + users_qs = get_user_model().objects.filter(**filter_kwargs).select_related('profile').order_by('id') + chunk_size = 100 + + for i in range(0, users_qs.count(), chunk_size): + yield users_qs[i:i + chunk_size] + # + # user_ids_list = get_user_model().objects.filter(**filter_kwargs).values_list('id', flat=True).order_by('id') + # user_chunks = grouper(user_ids_list) + # for user_ids in user_chunks: + # user_ids = [user_id for user_id in user_ids if user_id is not None] + # min_id = min(user_ids) + # max_id = max(user_ids) + # users = get_user_model().objects.filter( + # id__gte=min_id, + # id__lte=max_id, + # **filter_kwargs + # ).select_related('profile') + # + # yield users return get_enrolled_learners_for_course( course_id=self.context.course_id, @@ -743,7 +749,7 @@ def _error_headers(self): def _problem_grades_header(self): """Problem Grade report header.""" - return OrderedDict([('id', 'Student ID'), ('email', 'Email'), ('username', 'Username')]) + return OrderedDict([('id', 'Student ID'), ('email', 'Email'),('name', 'Name'), ('username', 'Username')]) def _rows_for_users(self, users): """ @@ -761,10 +767,20 @@ def _rows_for_users(self, users): # There was an error grading this student. if not err_msg: err_msg = 'Unknown error' + # error_rows.append( + # [student.id, student.email, student.username] + + # [err_msg] + # ) error_rows.append( - [student.id, student.email, student.username] + + [ + student.id, + student.email, + getattr(student.profile, "name", ""), # ← ДОБАВИЛИ + student.username + ] + [err_msg] ) + continue earned_possible_values = [] @@ -780,8 +796,18 @@ def _rows_for_users(self, users): earned_possible_values.append(['Not Attempted', problem_score.possible]) enrollment_status = _user_enrollment_status(student, self.context.course_id) + # success_rows.append( + # [student.id, student.email, student.username] + + # [enrollment_status, course_grade.percent] + + # _flatten(earned_possible_values) + # ) success_rows.append( - [student.id, student.email, student.username] + + [ + student.id, + student.email, + getattr(student.profile, "name", ""), # ← ДОБАВИЛИ + student.username + ] + [enrollment_status, course_grade.percent] + _flatten(earned_possible_values) ) diff --git a/lms/djangoapps/news/admin.py b/lms/djangoapps/news/admin.py new file mode 100644 index 000000000000..8443d1c799ac --- /dev/null +++ b/lms/djangoapps/news/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from .models import News + +@admin.register(News) +class NewsAdmin(admin.ModelAdmin): + list_display = ('title', 'created_at') + search_fields = ('title', 'content') \ No newline at end of file diff --git a/lms/djangoapps/news/forms.py b/lms/djangoapps/news/forms.py new file mode 100644 index 000000000000..98cbb2bb4df6 --- /dev/null +++ b/lms/djangoapps/news/forms.py @@ -0,0 +1,10 @@ +from django import forms +from .models import News + +class NewsForm(forms.ModelForm): + class Meta: + model = News + fields = ['title', 'content', 'image'] + widgets = { + 'content': forms.Textarea(attrs={'rows': 10}), + } \ No newline at end of file diff --git a/lms/djangoapps/news/migrations/0001_initial.py b/lms/djangoapps/news/migrations/0001_initial.py new file mode 100644 index 000000000000..67310398ac14 --- /dev/null +++ b/lms/djangoapps/news/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.20 on 2025-09-30 11:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='News', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='Заголовок')), + ('content', models.TextField(verbose_name='Содержание')), + ('image', models.ImageField(blank=True, null=True, upload_to='articles/', verbose_name='Изображение')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ], + options={ + 'verbose_name': 'Новость', + 'verbose_name_plural': 'Новости', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/lms/djangoapps/news/migrations/__init__.py b/lms/djangoapps/news/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/news/models.py b/lms/djangoapps/news/models.py new file mode 100644 index 000000000000..a8f19eb65335 --- /dev/null +++ b/lms/djangoapps/news/models.py @@ -0,0 +1,22 @@ +from django.db import models + +class News(models.Model): + title = models.CharField(max_length=200, verbose_name="Заголовок") + content = models.TextField(verbose_name="Содержание") + image = models.ImageField( + upload_to='articles/', + verbose_name="Изображение", + blank=True, + null=True + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") + + class Meta: + verbose_name = "Новость" + verbose_name_plural = "Новости" + ordering = ['-created_at'] + + def __str__(self): + return self.title + diff --git a/lms/djangoapps/news/views.py b/lms/djangoapps/news/views.py new file mode 100644 index 000000000000..98190738a0b5 --- /dev/null +++ b/lms/djangoapps/news/views.py @@ -0,0 +1,442 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.urls import reverse +from common.djangoapps.edxmako.shortcuts import render_to_response +from .models import News +from .forms import NewsForm +from django.contrib.auth.decorators import user_passes_test +from common.djangoapps.student.models import UserProfile + +import json +from django.utils import timezone +from collections import Counter +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +# from ..commerce.api.v1.models import Course +import logging +import jwt +import urllib.parse +import random +from datetime import datetime, timedelta + +from django.http import HttpResponseRedirect + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.conf import settings + + +from django.db.models.functions import ExtractYear +from django.db.models import Count + +logger = logging.getLogger(__name__) + +def news_list(request): + news = News.objects.all() + context = { + 'news_list': news, + 'create_url': reverse('news_create'), + } + return render_to_response('news/list.html', context, request=request) + +def news_detail(request, news_id): # используем news_id + news = get_object_or_404(News, pk=news_id) # все равно используем pk для поиска + context = { + 'news': news, + 'list_url': reverse('news_list'), + } + return render_to_response('news/detail.html', context, request=request) + + +@user_passes_test(lambda u: u.is_staff) +def news_create(request): + if request.method == 'POST': + form = NewsForm(request.POST, request.FILES) + if form.is_valid(): + form.save() + return redirect('news_list') + else: + form = NewsForm() + + context = { + 'form': form, + 'list_url': reverse('news_list'), + } + return render_to_response('news/form.html', context, request=request) + +# +from django.utils.translation import get_language + +FACULTY_TRANSLATIONS = { + "Биология и биотехнология": { + "kk": "Биология және биотехнология", + "en": "Biology and Biotechnology", + }, + "Востоковедение": { + "kk": "Шығыстану", + "en": "Oriental Studies", + }, + "Высшая школа экономики и бизнеса": { + "kk": "Экономика және бизнес жоғары мектебі", + "en": "Higher School of Economics and Business", + }, + "Довузовское образование": { + "kk": "Жоғары оқу орнына дейінгі білім беру", + "en": "Pre-university Education", + }, + "Журналистика": { + "kk": "Журналистика", + "en": "Journalism", + }, + "Офис академических и цифровых инноваций": { + "kk": "Академиялық және цифрлық инновациялар кеңсесі", + "en": "Office of Academic and Digital Innovations", + }, + "Информационные технологии и искусственный интеллект": { + "kk": "Ақпараттық технологиялар және жасанды интеллект", + "en": "Information Technology and Artificial Intelligence", + }, + "История": { + "kk": "Тарих", + "en": "History", + }, + "Кластер инжиниринга и наукоемких технологий": { + "kk": "Инжиниринг және жоғары технологиялар кластері", + "en": "Cluster of Engineering and High Technologies", + }, + "Механико-математический": { + "kk": "Механика-математика", + "en": "Mechanics and Mathematics", + }, + "Физико-технический": { + "kk": "Физика-техникалық", + "en": "Physics and Technology", + }, + "Филологический": { + "kk": "Филология", + "en": "Philology", + }, + "Философии и политологии": { + "kk": "Философия және саясаттану", + "en": "Philosophy and Political Science", + }, + "Химии и химической технологии": { + "kk": "Химия және химиялық технологиялар", + "en": "Chemistry and Chemical Technology", + }, + "Юридический": { + "kk": "Заң", + "en": "Law", + }, + "Международных отношений": { + "kk": "Халықаралық қатынастар", + "en": "International Relations", + }, + "Медицины и здравоохранения": { + "kk": "Медицина және денсаулық сақтау", + "en": "Medicine and Healthcare", + }, +} + +DIRECTION_TRANSLATIONS = { + "Социальные науки, журналистика и информация": { + "kk": "Әлеуметтік ғылымдар, журналистика және ақпарат", + "en": "Social sciences, Journalism and Information", + }, + "Естественные науки, математика и статистика": { + "kk": "Жаратылыстану ғылымдары, математика және статистика", + "en": "Natural Sciences, Mathematics and Statistics", + }, + "Искусство и гуманитарные науки": { + "kk": "Өнер және гуманитарлық ғылымдар", + "en": "Arts and Humanities", + }, + "Бизнес, управление и право": { + "kk": "Бизнес, басқару және құқық", + "en": "Business, Management and Law", + }, + "Педагогические науки": { + "kk": "Педагогикалық ғылымдар", + "en": "Pedagogical sciences", + }, + "Информационно-коммуникационные технологии": { + "kk": "Ақпараттық-коммуникациялық технологиялар", + "en": "Information and communication technologies", + }, + "Инженерные, обрабатывающие и строительные отрасли": { + "kk": "Инженерлік, өңдеу және құрылыс салалары", + "en": "Engineering, manufacturing and construction branches", + }, + "Здравоохранение": { + "kk": "Денсаулық сақтау", + "en": "Healthcare", + }, +} + + +def normalize_language_code(): + language = get_language() or "ru" + return language.split("-")[0].split("_")[0] + + +def translate_faculty(value): + if not value: + return "" + + language = normalize_language_code() + + if language == "ru": + return value + + return FACULTY_TRANSLATIONS.get(value, {}).get(language, value) + + +def translate_direction(value): + if not value: + return "" + + language = normalize_language_code() + + if language == "ru": + return value + + return DIRECTION_TRANSLATIONS.get(value, {}).get(language, value) +# +def analyze(request): + course_org_filter = ["Test_kaznu", "rty", "123", "AI Tools in Action: Boosting Productivity with Modern Workflows", "Demo"] + + now = timezone.now() + today = now.date() + current_year = today.year + + max_valid_end = now + timedelta(days=366) + + base_courses_qs = ( + CourseOverview.objects + .exclude(org__in=course_org_filter) + + # курс еще не должен закончиться + .exclude(end__isnull=True) + # .exclude(end__lt=now) + + # скрываем слишком долгие/ошибочные курсы, например до 2028 года + .exclude(end__gt=max_valid_end) + + # убираем пустые названия + .filter(start__lte=now) + .exclude(display_name__isnull=True) + .exclude(display_name="") + .order_by("display_name", "-start", "-id") + ) + + unique_courses = {} + for course in base_courses_qs: + if course.display_name not in unique_courses: + unique_courses[course.display_name] = course + + courses = list(unique_courses.values()) + + current_year_courses = [ + course for course in courses + if course.start and course.start.year == current_year + ] + # Rewrite code to test it # + current_year_courses = courses + # + max_year = current_year + 1 + + courses_by_year_qs = ( + CourseOverview.objects + .exclude(org__in=course_org_filter) + .exclude(start__isnull=True) + .filter(start__year__lte=max_year) + .annotate(year=ExtractYear("start")) + .values("year") + .annotate(total=Count("id")) + .order_by("year") + ) + courses_by_year = [ + {"year": row["year"], "total": row["total"]} + for row in courses_by_year_qs + ] + + # + faculty_counter = Counter( + course.faculty for course in courses + if course.faculty + ) + + directions_counter = Counter( + course.directions for course in courses + if course.directions + ) + + language_counter = Counter( + course.language for course in courses + if course.language + ) + + courses_by_faculty = [ + {"faculty": faculty, "total": total} + for faculty, total in faculty_counter.most_common(12) + ] + + courses_by_directions = [ + {"directions": directions, "total": total} + for directions, total in directions_counter.most_common(12) + ] + + courses_by_lang = [ + {"language": language, "total": total} + for language, total in language_counter.most_common() + ] + + top_courses = sorted( + current_year_courses, + key=lambda course: course.start, + reverse=True + ) + + course_run_counts = dict( + CourseOverview.objects + .exclude(org__in=course_org_filter) + .exclude(display_name__isnull=True) + .exclude(display_name="") + .exclude(start__isnull=True) + .values("display_name") + .annotate(total=Count("id", distinct=True)) + .values_list("display_name", "total") + ) + + courses_json = [ + { + "id": str(course.id), + "display_name": course.display_name or str(course.id), + "faculty": translate_faculty(course.faculty), + "directions": translate_direction(course.directions), + "language": course.language or "", + "start": course.start.strftime("%d.%m.%Y") if course.start else "", + "run_count": course_run_counts.get(course.display_name, 1), + "url": "/courses/{}/about".format(course.id), + } + for course in top_courses + ] + + context = { + "courses_count": len(courses), + "current_year": current_year, + "current_year_courses_count": len(current_year_courses), + "faculty_count": len(set(course.faculty for course in courses if course.faculty)), + "directions_count": len(set(course.directions for course in courses if course.directions)), + "generated_at": timezone.localtime().strftime("%d.%m.%Y %H:%M"), + + "language_summary": [ + {"label": row["language"], "total": row["total"]} + for row in courses_by_lang + ], + + "faculty_labels": json.dumps( + [translate_faculty(row["faculty"]) for row in courses_by_faculty], + ensure_ascii=False + ), + "faculty_data": json.dumps([row["total"] for row in courses_by_faculty]), + + "directions_labels": json.dumps( + [translate_direction(row["directions"]) for row in courses_by_directions], + ensure_ascii=False + ), + "directions_data": json.dumps([row["total"] for row in courses_by_directions]), + + "year_labels": json.dumps([row["year"] for row in courses_by_year]), + "year_data": json.dumps([row["total"] for row in courses_by_year]), + + "courses_json": json.dumps(courses_json, ensure_ascii=False), + } + + return render_to_response("news/analyze.html", context, request=request) + + + +PROCTORING_URL = "https://farabi-proctoring.kaznu.kz/integration/simple/kaznu_open/start/" + +def go_to_exam(request): + SECRET_KEY = str(settings.PROCTORING_API_KEY) + # ✅ 1. Берем данные из frontend + user_id = request.user.id + username = request.user.username + unit_url = request.GET.get("unit_url", "/") + course_name = request.GET.get("course_name", "empty") + section_name = request.GET.get("section_name", "empty") + + + # можно взять реальные данные если нужно + try: + name = request.user.profile.name.strip() + parts = name.split(maxsplit=1) + + firstname = parts[0] if len(parts) > 0 else "" + lastname = parts[1] if len(parts) > 1 else "" + except: + firstname, lastname = "None", "None" + + exam_id = random.randint(10**7, 10**8 - 1) + session_id = random.randint(10**10, 10**11 - 1) + exam_name = f"Экзамен по {course_name}" + request.session["proctoring_session_id"] = session_id + logger.info(f"my-log: exam start {session_id}") + + + # 2. Время + now = datetime.utcnow() + start_iso = now.isoformat() + "Z" + end_iso = (now + timedelta(hours=2)).isoformat() + "Z" + + # ✅ 3. Payload с динамическим возвратом + payload = { + "userId": user_id, + "lastName": lastname, + "firstName": firstname, + "thirdName": username, + "language": "ru", + "accountName": "kaznu_open", + "examId": exam_id, + "examName": exam_name, + "duration": 30, + "schedule": False, + "proctoring": "online", + "examDesc": f"Курс: {course_name}
    Модуль: {section_name}

    Ссылка на задание {unit_url}", + "rules": { + "websites": True, + "look_away": True, + "move_away": False, + "voices": True, + }, + "startDate": start_iso, + "endDate": end_iso, + "sessionId": session_id, + + # 🔥 ВАЖНО: возвращаем туда откуда пришли + "sessionUrl": unit_url, + "redirectUrl": unit_url, + } + + # 4. JWT + token = jwt.encode(payload, SECRET_KEY, algorithm="HS256") + + # 5. URL + encoded_token = urllib.parse.quote(token) + final_url = f"{PROCTORING_URL}?token={encoded_token}" + + logger.info(f"my-log: request get {request.GET}") + + return HttpResponseRedirect(final_url) + + +def finish_exam(request): + session_id = request.session.get("proctoring_session_id") + logger.info(f"my-log: exam finished {session_id}") + + redirect_url = request.GET.get("redirectUrl", "/") + logger.info(f"my-log: exam finished {redirect_url}") + + url = f"https://farabi-proctoring.kaznu.kz/integration/simple/kaznu_open/finish/{session_id}/?redirectUrl={redirect_url}" + + return HttpResponseRedirect(url) diff --git a/lms/djangoapps/static_template_view/urls.py b/lms/djangoapps/static_template_view/urls.py index 231913fbfcc1..0965a51b1244 100644 --- a/lms/djangoapps/static_template_view/urls.py +++ b/lms/djangoapps/static_template_view/urls.py @@ -5,6 +5,7 @@ from django.conf import settings from django.urls import path, re_path +from django.views.generic.base import RedirectView from lms.djangoapps.static_template_view import views @@ -15,10 +16,15 @@ path('faq', views.render, {'template': 'faq.html'}, name="faq"), path('help', views.render, {'template': 'help.html'}, name="help_edx"), path('jobs', views.render, {'template': 'jobs.html'}, name="jobs"), - path('news', views.render, {'template': 'news.html'}, name="news"), path('press', views.render, {'template': 'press.html'}, name="press"), path('media-kit', views.render, {'template': 'media-kit.html'}, name="media-kit"), path('copyright', views.render, {'template': 'copyright.html'}, name="copyright"), + path('competition', views.render, {'template': 'competition.html'}, name="competition"), + path('catalog_transfer', views.render, {'template': 'catalog_transfer.html'}, name="catalog_transfer"), + path('catalog', RedirectView.as_view(pattern_name='catalog_transfer', permanent=False), name="catalog"), + path('author', views.render, {'template': 'author.html'}, name="author"), + path('detect', views.render, {'template': 'detect.html'}, name="detect"), + path('honor_code', views.render, {'template': 'honor_code.html'}, name="honor_code"), # Press releases re_path(r'^press/([_a-zA-Z0-9-]+)$', views.render_press_release, name='press_release'), diff --git a/lms/djangoapps/univerapi/admin.py b/lms/djangoapps/univerapi/admin.py new file mode 100644 index 000000000000..ea5d68b7c457 --- /dev/null +++ b/lms/djangoapps/univerapi/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/lms/djangoapps/univerapi/views.py b/lms/djangoapps/univerapi/views.py new file mode 100644 index 000000000000..a58bd64c06a6 --- /dev/null +++ b/lms/djangoapps/univerapi/views.py @@ -0,0 +1,194 @@ +import jwt +import requests +from django.contrib.auth import login, authenticate, get_user_model +from django.http import HttpResponseRedirect +from django.views.decorators.csrf import csrf_exempt +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from common.djangoapps.student.models import UserProfile + +SECRET_KEY = "$ecRet@3#$2958GPIs!1" +User = get_user_model() + + +class UniverTestView(APIView): + authentication_classes = [] + permission_classes = [] + + @csrf_exempt + def get(self, request): + auth_token = request.GET.get('auth') + if not auth_token: + return Response({'error': 'Missing auth token'}, status=status.HTTP_400_BAD_REQUEST) + + try: + decoded = jwt.decode(auth_token, SECRET_KEY, algorithms=['HS256']) + uname = decoded.get('uname') + upwd = decoded.get('upwd') + + if not uname or not upwd: + return Response({'error': 'Invalid token payload'}, status=status.HTTP_400_BAD_REQUEST) + + username = uname + email_value = f"{username}@open.edu.kz" + + # Получаем данные профиля из Univer API + surname, name, gender, stage, birth_year = decode_token_and_fetch_profile(auth_token) + + # Проверка пользователя + user = User.objects.filter(username=username).first() + + if not user: + # Создание нового пользователя + user = User.objects.create(username=username, email=email_value) + user.set_password(upwd) + user.save() + + UserProfile.objects.create( + user=user, + name=f"{name} {surname}", + country='KZ', + gender=gender, + level_of_education=stage, + year_of_birth=birth_year, + mailing_address='Kaznu', + goals='Цель обучаться на платформе ОпенКазну' + ) + else: + # Обновляем пароль при изменении + if not user.check_password(upwd): + user.set_password(upwd) + user.save() + + # Проверка и обновление профиля + profile, created = UserProfile.objects.get_or_create(user=user) + updated = False + + if profile.name != f"{name} {surname}": + profile.name = f"{name} {surname}" + updated = True + if profile.gender != gender: + profile.gender = gender + updated = True + if profile.level_of_education != stage: + profile.level_of_education = stage + updated = True + if profile.year_of_birth != birth_year: + profile.year_of_birth = birth_year + updated = True + if profile.country != 'KZ': + profile.country = 'KZ' + updated = True + if profile.mailing_address != 'Kaznu': + profile.mailing_address = 'Kaznu' + updated = True + + if updated: + profile.save() + + # Авторизация + auth_user = authenticate(username=username, password=upwd) + if auth_user is None: + return Response({'error': 'Authentication failed'}, status=status.HTTP_401_UNAUTHORIZED) + + login(request, auth_user) + return HttpResponseRedirect('/dashboard') + + except jwt.ExpiredSignatureError: + return Response({'error': 'Token expired'}, status=status.HTTP_401_UNAUTHORIZED) + except jwt.InvalidTokenError: + return Response({'error': 'Invalid token'}, status=status.HTTP_401_UNAUTHORIZED) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + +def decode_token_and_fetch_profile(token, secret_key=SECRET_KEY): + """Декодирование токена и получение профиля пользователя с Univer API""" + try: + decoded = jwt.decode(token, secret_key, algorithms=['HS256']) + except jwt.InvalidSignatureError: + raise SystemExit("Ошибка: неверная подпись JWT") + except jwt.DecodeError as e: + raise SystemExit(f"Ошибка при декодировании JWT: {e}") + + uname = decoded.get('uname') + upwd = decoded.get('upwd') + if not uname or not upwd: + raise SystemExit("Ошибка: в токене нет 'uname' или 'upwd'") + + session = requests.Session() + resp = session.post( + "https://univerapi.kaznu.kz/user/loginMoodle", + data={'login': uname, 'password': upwd}, + ) + resp.raise_for_status() + data = resp.json() + if data.get('code') != 0: + raise SystemExit(f"loginMoodle вернул: {data.get('message', '')}") + + def find_value(lst, key): + for d in lst: + if key in d and ':' in d[key]: + return d[key].split(':', 1)[1].strip() + return None + + # 1. Пробуем студентский профиль + try: + student_resp = session.get("https://univerapi.kaznu.kz/student/profile") + if student_resp.status_code == 200: + student_data = student_resp.json() + if student_data.get('code') == 0 and 'data' in student_data: + info_list, personal_list = student_data['data'] + + surname = find_value(personal_list, 'sname') + name = find_value(personal_list, 'name') + + raw_sex = (find_value(personal_list, 'sex') or '').lower() + if u'муж' in raw_sex: + gender = 'm' + elif u'жен' in raw_sex: + gender = 'f' + else: + gender = 'o' + + raw_stage = (find_value(info_list, 'stage') or '').lower() + if u'бакалав' in raw_stage: + stage = 'b' + elif u'магис' in raw_stage: + stage = 'm' + elif u'доктор' in raw_stage: + stage = 'p' + else: + stage = 'none' + + birth_year = 1995 + return surname, name, gender, stage, birth_year + except: + pass + + # 2. Пробуем профиль преподавателя + try: + teacher_resp = session.get("https://univerapi.kaznu.kz/teacher/profile") + if teacher_resp.status_code == 200: + teacher_data = teacher_resp.json() + if teacher_data.get('code') == 0 and 'data' in teacher_data: + teacher_profile = teacher_data['data'][0] + surname = teacher_profile.get('sname', '') + name = teacher_profile.get('name', '') + gender = 'o' + stage = 'none' + birth_year = None + if 'dateOfBirth' in teacher_profile: + try: + birth_year = int(teacher_profile['dateOfBirth'].split('.')[-1]) + except (ValueError, IndexError): + pass + return surname, name, gender, stage, birth_year + except: + pass + + + return "Сотрудник", "Казну", "o", "none", 1995 + + + return surname, name, gender, stage, birth_year diff --git a/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py index 04f75ef42439..0bfef6d0ac91 100644 --- a/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py +++ b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py @@ -188,10 +188,11 @@ def send_verification_expiry_email(self, batch_verifications, email_config): return True site = Site.objects.get_current() + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') message_context = get_base_template_context(site) message_context.update({ 'platform_name': settings.PLATFORM_NAME, - 'lms_verification_link': f'{settings.ACCOUNT_MICROFRONTEND_URL}/id-verification', + 'lms_verification_link': f'{account_base_url}/id-verification', 'help_center_link': settings.ID_VERIFICATION_SUPPORT_LINK }) diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index 95dbccf0d59d..5caede3dab83 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -251,7 +251,8 @@ def get_verify_location(cls, course_id=None): Returns a string: Returns URL for IDV on Account Microfrontend """ - location = f'{settings.ACCOUNT_MICROFRONTEND_URL}/id-verification' + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') + location = f'{account_base_url}/id-verification' if course_id: location += f'?course_id={quote(str(course_id))}' diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 1b6a47bee879..deda08e0c741 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -1128,7 +1128,8 @@ def results_callback(request): # lint-amnesty, pylint: disable=too-many-stateme log.info("[COSMO-184] Denied verification for receipt_id={receipt_id}.".format(receipt_id=receipt_id)) attempt.deny(json.dumps(reason), error_code=error_code) - reverify_url = f'{settings.ACCOUNT_MICROFRONTEND_URL}/id-verification' + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') + reverify_url = f'{account_base_url}/id-verification' verification_status_email_vars['reasons'] = reason verification_status_email_vars['reverify_url'] = reverify_url verification_status_email_vars['faq_url'] = settings.ID_VERIFICATION_SUPPORT_LINK diff --git a/lms/envs/common.py b/lms/envs/common.py index 763bd83b9d8e..4b93d72f6977 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1985,7 +1985,7 @@ def _make_mako_template_dirs(settings): ('id', 'Bahasa Indonesia'), # Indonesian ('it-it', 'Italiano (Italia)'), # Italian (Italy) ('ja-jp', '日本語 (日本)'), # Japanese (Japan) - ('kk-kz', 'қазақ тілі (Қазақстан)'), # Kazakh (Kazakhstan) + ('kk', 'Қазақша'), # Kazakh (Kazakhstan) ('km-kh', 'ភាសាខ្មែរ (កម្ពុជា)'), # Khmer (Cambodia) ('kn', 'ಕನ್ನಡ'), # Kannada ('ko-kr', '한국어 (대한민국)'), # Korean (Korea) @@ -3033,6 +3033,8 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # - Make it a plugin (which are auto-registered) and add it to the EDXAPP_PRIVATE_REQUIREMENTS configuration variable # (See https://github.com/openedx/edx-django-utils/tree/master/edx_django_utils/plugins) INSTALLED_APPS = [ + 'lms.djangoapps.univerapi', + 'lms.djangoapps.news', # Standard ones that are always installed... 'django.contrib.auth', 'django.contrib.contenttypes', @@ -3441,7 +3443,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # It's POST endpoint is rate-limited up to 30 requests per IP Address in a week by default. # It was introduced because an attacker can guess or brute force a series of names to enumerate valid users. # .. setting_tickets: https://github.com/openedx/edx-platform/pull/24664 -REGISTRATION_VALIDATION_RATELIMIT = '30/7d' +REGISTRATION_VALIDATION_RATELIMIT = '10000/7d' # .. setting_name: REGISTRATION_RATELIMIT # .. setting_default: 60/7d @@ -3449,7 +3451,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # It's POST end-point is rate-limited up to 60 requests per IP Address in a week by default. # Purpose of this setting is to restrict an attacker from registering numerous fake accounts. # .. setting_tickets: https://github.com/openedx/edx-platform/pull/27060 -REGISTRATION_RATELIMIT = '60/7d' +REGISTRATION_RATELIMIT = '10000/7d' SWAGGER_SETTINGS = { 'DEFAULT_INFO': 'openedx.core.apidocs.api_info', @@ -4881,7 +4883,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2018-01-08 # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/16951 -RATELIMIT_ENABLE = True +RATELIMIT_ENABLE = False # .. setting_name: RATELIMIT_RATE # .. setting_default: 120/m @@ -4892,13 +4894,13 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # .. setting_use_cases: open_edx # .. setting_creation_date: 2018-01-08 # .. setting_tickets: https://github.com/openedx/edx-platform/pull/16951 -RATELIMIT_RATE = '120/m' +RATELIMIT_RATE = '1200/m' ##### LOGISTRATION RATE LIMIT SETTINGS ##### -LOGISTRATION_RATELIMIT_RATE = '100/5m' -LOGISTRATION_PER_EMAIL_RATELIMIT_RATE = '30/5m' -LOGISTRATION_API_RATELIMIT = '20/m' -LOGIN_AND_REGISTER_FORM_RATELIMIT = '100/5m' +LOGISTRATION_RATELIMIT_RATE = '6000/5m' +LOGISTRATION_PER_EMAIL_RATELIMIT_RATE = '300/5m' +LOGISTRATION_API_RATELIMIT = '1000/m' +LOGIN_AND_REGISTER_FORM_RATELIMIT = '1000000/5m' RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT = '30/7d' RESET_PASSWORD_API_RATELIMIT = '30/7d' OPTIONAL_FIELD_API_RATELIMIT = '10/h' diff --git a/lms/lib/courseware_search/lms_filter_generator.py b/lms/lib/courseware_search/lms_filter_generator.py index b0c0564df48a..5b2592e4cd19 100644 --- a/lms/lib/courseware_search/lms_filter_generator.py +++ b/lms/lib/courseware_search/lms_filter_generator.py @@ -9,6 +9,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme from common.djangoapps.student.models import CourseEnrollment +from xmodule.course_block import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE INCLUDE_SCHEMES = [CohortPartitionScheme, RandomUserPartitionScheme, ] SCHEME_SUPPORTS_ASSIGNMENT = [RandomUserPartitionScheme, ] @@ -63,6 +64,6 @@ def exclude_dictionary(self, **kwargs): if not getattr(settings, "SEARCH_SKIP_INVITATION_ONLY_FILTERING", True): exclude_dictionary['invitation_only'] = True if not getattr(settings, "SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING", True): - exclude_dictionary['catalog_visibility'] = 'none' + exclude_dictionary['catalog_visibility'] = [CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE] return exclude_dictionary diff --git a/lms/lib/courseware_search/test/test_lms_filter_generator.py b/lms/lib/courseware_search/test/test_lms_filter_generator.py index 492cf64d8c30..8afa94f70fbf 100644 --- a/lms/lib/courseware_search/test/test_lms_filter_generator.py +++ b/lms/lib/courseware_search/test/test_lms_filter_generator.py @@ -6,6 +6,7 @@ from lms.lib.courseware_search.lms_filter_generator import LmsSearchFilterGenerator from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory +from xmodule.course_block import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -139,3 +140,9 @@ def test_excludes_multi_orgs_within(self): assert 'org' not in exclude_dictionary assert 'org' in field_dictionary assert ['TestSite3'] == field_dictionary['org'] + + @patch('django.conf.settings.SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING', False) + def test_excludes_catalog_visibility(self): + _, _, exclude_dictionary = LmsSearchFilterGenerator.generate_field_filters(user=self.user) + assert 'catalog_visibility' in exclude_dictionary + assert exclude_dictionary['catalog_visibility'] == [CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE] diff --git a/lms/static/images/favicon.ico b/lms/static/images/favicon.ico index 0347608a33d7..c9feabafecc0 100644 Binary files a/lms/static/images/favicon.ico and b/lms/static/images/favicon.ico differ diff --git a/lms/static/images/logo.png b/lms/static/images/logo.png index 640d80b956fb..e355386c98d2 100644 Binary files a/lms/static/images/logo.png and b/lms/static/images/logo.png differ diff --git a/lms/static/images/programs/sample-cert.png b/lms/static/images/programs/sample-cert.png index 09de8897d3a2..4639a97f8cd0 100644 Binary files a/lms/static/images/programs/sample-cert.png and b/lms/static/images/programs/sample-cert.png differ diff --git a/lms/static/images/sign1.png b/lms/static/images/sign1.png new file mode 100644 index 000000000000..40e13c2af18c Binary files /dev/null and b/lms/static/images/sign1.png differ diff --git a/lms/static/images/sign2.jpg b/lms/static/images/sign2.jpg new file mode 100644 index 000000000000..2aae0e1ca863 Binary files /dev/null and b/lms/static/images/sign2.jpg differ diff --git a/lms/templates/certificates/_about-accomplishments.html b/lms/templates/certificates/_about-accomplishments.html index be4d50a7ec9e..c847f75e4f7b 100644 --- a/lms/templates/certificates/_about-accomplishments.html +++ b/lms/templates/certificates/_about-accomplishments.html @@ -1,8 +1 @@ <%page expression_filter="h"/> -
    -

    ${accomplishment_copy_about}

    - -
    -

    ${certificate_info_description}

    -
    -
    diff --git a/lms/templates/certificates/_accomplishment-banner.html b/lms/templates/certificates/_accomplishment-banner.html index 15cab1247697..742907217ca9 100644 --- a/lms/templates/certificates/_accomplishment-banner.html +++ b/lms/templates/certificates/_accomplishment-banner.html @@ -40,53 +40,3 @@
    -
    - -
    diff --git a/lms/templates/certificates/_accomplishment-rendering.html b/lms/templates/certificates/_accomplishment-rendering.html index 7c5ae957f39b..93876d40dd3f 100644 --- a/lms/templates/certificates/_accomplishment-rendering.html +++ b/lms/templates/certificates/_accomplishment-rendering.html @@ -2,118 +2,337 @@ <%! from django.utils.translation import gettext as _ %> <%namespace name='static' file='../static_content.html'/> <% +from datetime import datetime course_mode_class = course_mode if course_mode else '' + +# Преобразуем дату в формат дд.мм.гггг +try: + date_obj = datetime.strptime(certificate_date_issued, "%B %d, %Y") + formatted_date = date_obj.strftime("%d.%m.%Y") +except: + formatted_date = certificate_date_issued + +# Создаем URL для QR кода +qr_code_url = "https://open.kaznu.kz/certificates/" + certificate_id_number %> +<%! import re %> -
    + + -
    -
    - - - - + + -
    - - Image for course mode of type ${course_mode_class} - + + + +
    +
    ⚠️
    +

    Внимание пользователям iPhone!

    +

    На устройствах Apple сертификат может скачиваться некорректно.

    +

    Рекомендуем: Для гарантированно качественного результата скачайте сертификат с компьютера или ноутбука.

    +

    + Если скачиваете с iPhone - проверьте качество файла после загрузки +

    +
    + +
    +
    +
    +
    + + +
    + QR Code
    -
    - +
    + + + + diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index eec9caeadbec..ac82a6dbfb1d 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -65,7 +65,26 @@

    ${course.display_org_with_default}

    ${course.display_name_with_default}


    -

    ${get_course_about_section(request, course, 'short_description')}

    + % if same_name_courses: +
    + + +
    +% endif + +
    @@ -206,6 +225,17 @@

    ${course.display_name_with_default}

  • ${_("Estimated Effort")}

    ${get_course_about_section(request, course, "effort")}
  • % endif + % if complexity: +
  • + +

    + ${_("Course Difficulty")} +

    + + ${_(complexity)} + +
  • + % endif ##
  • ${_('Course Length')}

    ${_('{number} weeks').format(number=15)}
  • %if course_price and (is_cosmetic_price_enabled): diff --git a/lms/templates/courseware/course_about_sidebar_header.html b/lms/templates/courseware/course_about_sidebar_header.html index 7f59ed1f2ffb..d50075dfa89c 100644 --- a/lms/templates/courseware/course_about_sidebar_header.html +++ b/lms/templates/courseware/course_about_sidebar_header.html @@ -48,14 +48,24 @@ body=six.moves.urllib.parse.quote(email_body.encode('UTF-8')) ) %> +
    % endif diff --git a/lms/templates/courseware/courses.html b/lms/templates/courseware/courses.html index 11ad5079ea65..1def4fbdd122 100644 --- a/lms/templates/courseware/courses.html +++ b/lms/templates/courseware/courses.html @@ -1,81 +1,356 @@ <%page expression_filter="h"/> <%! - import json from django.utils.translation import gettext as _ - from openedx.core.djangolib.js_utils import js_escaped_string, dump_js_escaped_json %> <%inherit file="../main.html" /> -<% - course_discovery_enabled = settings.FEATURES.get('ENABLE_COURSE_DISCOVERY') -%> -<%namespace name='static' file='../static_content.html'/> +<%block name="pagetitle">${_("Courses")} -% if course_discovery_enabled: -<%block name="header_extras"> - % for template_name in ["course_card", "filter_bar", "filter", "facet", "facet_option"]: - - % endfor - <%static:require_module module_name="js/discovery/discovery_factory" class_name="DiscoveryFactory"> - DiscoveryFactory( - ${course_discovery_meanings | n, dump_js_escaped_json}, - getParameterByName('search_query'), - "${user_language | n, js_escaped_string}", - "${user_timezone | n, js_escaped_string}", - ${set_default_filter | n, dump_js_escaped_json} - ); - +<%block name="headextra"> + -% endif -<%block name="pagetitle">${_("Courses")} +<%block name="content"> +
    +

    ${_("Courses")}

    +

    ${_("Explore our collection of educational programs and start learning today")}

    +
    + +
    +
    +

    ${_("Filter Courses")}

    +
    + + + -
    -
    -
    - % if course_discovery_enabled: - + + +
    + % if courses: + % for course in courses: + <% + from django.utils import timezone + now = timezone.now() + status = "completed" if course.end and course.end < now else "ongoing" + status_text = _('Completed') if status == "completed" else _('Ongoing') + %> +
    +
    + ${course.display_name} +
    ${status_text}
    +
    +
    +

    ${course.display_name}

    +
    + ${course.display_org_with_default} + % if course.language: + ${course.language} + % endif
    - - +
    + ${_('Start')}: ${course.start.strftime('%Y-%m-%d') if course.start else _('Not specified')} + ${_('End')}: ${course.end.strftime('%Y-%m-%d') if course.end else _('Not specified')} +
    + ${_('Go to course')} +
    +
    + % endfor + % else: +
    + 📚 +

    ${_('No courses found')}

    +

    ${_('There are currently no available courses')}

    + % endif +
    +
    - - % endif - -
    -
      - %for course in courses: -
    • - <%include file="../course.html" args="course=course" /> -
    • - %endfor -
    -
    + + - % if course_discovery_enabled: - - % endif - - -
    diff --git a/lms/templates/courseware/courses_basic.html b/lms/templates/courseware/courses_basic.html new file mode 100644 index 000000000000..f8826922a3be --- /dev/null +++ b/lms/templates/courseware/courses_basic.html @@ -0,0 +1,353 @@ +<%page expression_filter="h"/> +<%! + from django.utils.translation import gettext as _ +%> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Courses")} + +<%block name="headextra"> + + + +<%block name="content"> +
    +

    ${_("Courses")}

    +

    ${_("Explore our collection of educational programs and start learning today")}

    +
    + +
    +
    +

    ${_("Filter Courses")}

    +
    + + + + + + + +
    +
    + +
    + % if courses: + % for course in courses: + <% + from django.utils import timezone + now = timezone.now() + status = "completed" if course.end and course.end < now else "ongoing" + status_text = _('Completed') if status == "completed" else _('Ongoing') + %> +
    +
    + ${course.display_name} +
    ${status_text}
    +
    +
    +

    ${course.display_name}

    +
    + ${course.display_org_with_default} + % if course.language: + ${course.language} + % endif +
    +
    + ${_('Start')}: ${course.start.strftime('%Y-%m-%d') if course.start else _('Not specified')} + ${_('End')}: ${course.end.strftime('%Y-%m-%d') if course.end else _('Not specified')} +
    + ${_('Go to course')} +
    +
    + % endfor + % else: +
    + 📚 +

    ${_('No courses found')}

    +

    ${_('There are currently no available courses')}

    +
    + % endif +
    +
    + + + diff --git a/lms/templates/courseware/courses_summer.html b/lms/templates/courseware/courses_summer.html new file mode 100644 index 000000000000..ef2c0ffb9f0c --- /dev/null +++ b/lms/templates/courseware/courses_summer.html @@ -0,0 +1,1009 @@ +<%page expression_filter="h"/> +<%! + from django.utils.translation import gettext as _ +%> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Courses")} + +<%block name="headextra"> + + + +<%block name="content"> +
    + + +
    + + + + + +
    +
    ${_("Course catalog")}
    +

    ${_("Courses")}

    +

    ${_("Explore our collection of educational programs and start learning today")}

    + +
    +
    + ${len(courses)} + ${_("Courses")} +
    +
    + ${len(set([c.language for c in courses if c.language]))} + ${_("Languages")} +
    +
    + ${len(set([c.faculty for c in courses if c.faculty]))} + ${_("Faculties")} +
    +
    +
    +
    + +
    +
    +
    +

    ${_("Filter Courses")}

    + + +
    + +
    + + + + + + + +
    +
    + +
    + % if courses: + % for course in courses: + <% + from django.utils import timezone + now = timezone.now() + status = "completed" if course.end and course.end < now else "ongoing" + status_text = _('Completed') if status == "completed" else _('Ongoing') + %> +
    +
    + ${course.display_name or _('Course')} +
    ${status_text}
    +
    + +
    +

    ${course.display_name}

    + +
    + ${course.display_org_with_default} + % if course.language: + ${course.language} + % endif +
    + +
    + ${_('Start')}: ${course.start.strftime('%Y-%m-%d') if course.start else _('Not specified')} + ${_('End')}: ${course.end.strftime('%Y-%m-%d') if course.end else _('Not specified')} +
    + + ${_('Go to course')} +
    +
    + % endfor + % else: +
    + +

    ${_('No courses found')}

    +

    ${_('There are currently no available courses')}

    +
    + % endif +
    +
    +
    + + + + + diff --git a/lms/templates/courseware/courses_winter.html b/lms/templates/courseware/courses_winter.html new file mode 100644 index 000000000000..72b206c0f6bb --- /dev/null +++ b/lms/templates/courseware/courses_winter.html @@ -0,0 +1,585 @@ +<%page expression_filter="h"/> +<%! +from django.utils.translation import gettext as _ +%> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Courses")} + +<%block name="headextra"> + + + +<%block name="content"> +
    +

    ${_("Courses")}

    +

    ${_("Explore our collection of educational programs and start learning today")}

    +
    + +
    +
    +

    ${_("Filter Courses")}

    +
    + + + + + + + +
    +
    + +
    + % if courses: + % for course in courses: + <% + from django.utils import timezone + now = timezone.now() + status = "completed" if course.end and course.end < now else "ongoing" + status_text = _('Completed') if status == "completed" else _('Ongoing') + %> +
    +
    + ${course.display_name} +
    ${status_text}
    +
    +
    +

    ${course.display_name}

    +
    + ${course.display_org_with_default} + % if course.language: + ${course.language} + % endif +
    +
    + ${_('Start')} + : ${course.start.strftime('%Y-%m-%d') if course.start else _('Not specified')} + ${_('End')}: ${course.end.strftime('%Y-%m-%d') if course.end else _('Not specified')} +
    + ${_('Go to course')} +
    +
    + % endfor + % else: +
    + 📚 +

    ${_('No courses found')}

    +

    ${_('There are currently no available courses')}

    +
    + % endif +
    +
    + + + + diff --git a/lms/templates/footer.html b/lms/templates/footer.html index f996030a7179..51a2ad1a8c7a 100644 --- a/lms/templates/footer.html +++ b/lms/templates/footer.html @@ -9,157 +9,109 @@ <% footer = get_footer(is_secure=is_secure) %> <% icp_license_info = getattr(settings, 'ICP_LICENSE_INFO', {})%> <%namespace name='static' file='static_content.html'/> + + - -% endif -% if include_dependencies: - <%static:js group='base_vendor'/> - <%static:css group='style-vendor'/> - <%include file="widgets/segment-io.html" /> - <%include file="widgets/segment-io-footer.html" /> -% endif -% if footer_css_urls: - % for url in footer_css_urls: - - % endfor -% endif diff --git a/lms/templates/header/header.html b/lms/templates/header/header.html index 3fafde87e07f..e5ddd9cb6966 100644 --- a/lms/templates/header/header.html +++ b/lms/templates/header/header.html @@ -1,5 +1,3 @@ -## mako - <%page expression_filter="h" args="online_help_token, use_cookie_banner=False"/> <%namespace name='static' file='../static_content.html'/> @@ -20,6 +18,9 @@ %> ## Provide a hook for themes to inject branding on top. + + + <%block name="navigation_top" /> <% @@ -41,6 +42,26 @@ catch(e){window.attachEvent("onload", $buo_f)} % endif + + + + % if course: +
    + +
    +
    +
    +
    ${_("MOOC")}
    +
    ${_("Online")}
    +
    ${_("Courses")}
    +
    ${_("Open")}
    +
    ${_("Massive")}
    +
    +
    +
    +

    ${_("About MOOC")}

    +

    + ${_("Massive Open Online Courses (MOOCs) are free online courses available to everyone. MOOCs provide a flexible and accessible way to gain new skills, advance your career, and deliver quality education at scale.")} +

    +

    + ${_("Millions of people worldwide use MOOCs for various reasons, including career advancement, career change, college preparation, additional education, lifelong learning, corporate e-learning, training, and much more.")} +

    +

    + ${_("MOOCs have fundamentally changed the way people learn around the world. Ready to get started?")} +

    + + +
    + ${_("Our News")}!
    + ${_("Check out our latest updates on the platform")} +
    +
    + +
    +
    + +
    + + + + + + + + + +
    +
    +

    ${_("Advantages of Online Courses")}

    + +
    +

    + + ${_("Flexible Schedule")} +

    +

    ${_("Learn at your own pace and at a convenient time, balancing studies with work or other commitments.")}

    +
    + +
    +

    + + ${_("Accessibility")} +

    +

    ${_("Get access to high-quality courses from anywhere in the world, without leaving your home.")}

    +
    + +
    +

    + + ${_("Variety of Courses")} +

    +

    ${_("Choose from a wide range of programs: from programming to personal development.")}

    +
    + +
    +

    + + ${_("Certificates")} +

    +

    ${_("Earn official certificates upon course completion to strengthen your resume.")}

    +
    + +
    +

    + + ${_("Community")} +

    +

    ${_("Connect with instructors and students, share experiences, and exchange ideas.")}

    +
    + +
    +

    + + ${_("Free Learning")} +

    +

    ${_("Many courses are available for free, making education accessible to everyone.")}

    +
    +
    +
    diff --git a/lms/templates/main.html b/lms/templates/main.html index 1a11900dae58..85861d47834b 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -33,6 +33,7 @@ + ## Define a couple of helper functions to make life easier when ## embedding theme conditionals into templates. All inheriting @@ -132,7 +133,15 @@ ${render_require_js_path_overrides(settings.REQUIRE_JS_PATH_OVERRIDES) | n, decode.utf8} - <%block name="headextra"/> + <%block name="headextra"> + + + <%block name="head_extra"/> <%include file="/courseware/experiments.html"/> @@ -211,7 +220,7 @@
    <%block name="marketing_hero">
    -
    +
    ${next.body()} <%block name="bodyextra"/>
    @@ -248,3 +257,7 @@ next=quote_plus(login_redirect_url if login_redirect_url else request.path) ) if (login_redirect_url or (request and not request.path.startswith("/logout"))) else "" } + + diff --git a/lms/templates/news/analyze.html b/lms/templates/news/analyze.html new file mode 100644 index 000000000000..ae0c2cc6299d --- /dev/null +++ b/lms/templates/news/analyze.html @@ -0,0 +1,829 @@ +<%page expression_filter="h"/> +<%! from django.utils.translation import gettext as _ %> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Analyze")} + +<%block name="extrahead"> + + + + +<%block name="content"> +
    +
    +
    +
    +
    ${_("Course analytics")}
    +

    ${_("Course analysis")}

    +

    + ${_("Summary of published courses without test organizations. The current courses of the current year are shown below; schedules of faculties and directions filter the list.")} +

    +
    +
    ${_("Updated")}: ${generated_at}
    +
    + +
    +
    +
    ${_("Total courses")}
    +
    ${courses_count}
    +
    ${_("Without test courses")}
    +
    +
    +
    ${_("Current issues")} ${current_year}
    +
    ${current_year_courses_count}
    +
    ${_("Used in the list below")}
    +
    +
    +
    ${_("Faculties")}
    +
    ${faculty_count}
    +
    ${_("With filled faculty field")}
    +
    +
    +
    ${_("Directions")}
    +
    ${directions_count}
    +
    ${_("With filled directions field")}
    +
    +
    + +
    + ${_("Languages")}: + % if language_summary: + % for language in language_summary: + ${language["label"]}: ${language["total"]} + % endfor + % else: + ${_("No data")} + % endif +
    + +
    +
    +
    +

    ${_("Courses by year")}

    +

    ${_("Course count dynamics by start date.")}

    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +

    ${_("Course Structure")}

    +

    ${_("Click a faculty or direction bar to filter the current courses below.")}

    +
    +
    +
    +
    +
    +
    +

    ${_("Courses by faculty")}

    +

    ${_("Top faculties by number of courses.")}

    +
    +
    +
    + +
    +
    + +
    +
    +
    +

    ${_("Courses by directions")}

    +

    ${_("Top directions by number of courses.")}

    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +

    ${_("Top 50 courses")}

    +

    ${_("Current courses with a start date in")} ${current_year} ${_("year")}.

    +
    +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + + + + + + + + + + + + + +
    ${_("Course")}${_("Faculty")}${_("Direction")}${_("Language")}${_("Launches")}${_("Start")}
    +
    +
    ${_("There are no current courses for the selected filter in")} ${current_year} ${_("year")} + . +
    +
    +
    +
    +
    + + +<%block name="extrajs"> + + diff --git a/lms/templates/news/analyze_save.html b/lms/templates/news/analyze_save.html new file mode 100644 index 000000000000..5a9e69f3e813 --- /dev/null +++ b/lms/templates/news/analyze_save.html @@ -0,0 +1,775 @@ +<%page expression_filter="h"/> +<%! from django.utils.translation import gettext as _ %> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Analyze")} + +<%block name="extrahead"> + + + + +<%block name="content"> +
    +
    +
    +
    +
    Course analytics
    +

    ${_("Course analysis")}

    +

    + ${_("Summary of published courses without test organizations. The current courses of the current year are shown below; schedules of faculties and directions filter the list")}. +

    +
    +
    ${_("Updated")}: ${generated_at}
    +
    + +
    +
    +
    ${_("Total courses")}
    +
    ${courses_count}
    +
    ${_("Without test courses")}
    +
    +
    +
    ${_("Current issues")} ${current_year}
    +
    ${current_year_courses_count}
    +
    Используются в списке ниже
    +
    +
    +
    Факультетов
    +
    ${faculty_count}
    +
    С заполненным полем faculty
    +
    +
    +
    Направлений
    +
    ${directions_count}
    +
    С заполненным полем directions
    +
    +
    + +
    + Языки: + % if language_summary: + % for language in language_summary: + ${language["label"]}: ${language["total"]} + % endfor + % else: + Нет данных + % endif +
    + +
    +
    +
    +

    Курсы по годам

    +

    Динамика количества курсов по дате старта.

    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +

    Структура курсов

    +

    Нажмите на столбец факультета или направления, чтобы отфильтровать актуальные курсы ниже.

    +
    +
    +
    +
    +
    +
    +

    Курсы по факультетам

    +

    Топ факультетов по количеству курсов.

    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Курсы по направлениям

    +

    Топ направлений по количеству курсов.

    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +

    Курсы топ 50

    +

    Актуальные курсы с датой старта в ${current_year} году.

    +
    +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + + + + + + + + + + + + +
    КурсФакультетНаправлениеЯзыкСтарт
    +
    +
    По выбранному фильтру нет актуальных курсов за ${current_year} год.
    +
    +
    +
    +
    + + +<%block name="extrajs"> + + diff --git a/lms/templates/news/detail.html b/lms/templates/news/detail.html new file mode 100644 index 000000000000..389e10840b91 --- /dev/null +++ b/lms/templates/news/detail.html @@ -0,0 +1,106 @@ +<%! from django.utils.html import escape %> + + + + + ${escape(news.title)} + + + +
    +
    +

    ${escape(news.title)}

    + ${news.created_at.date()} + + % if news.image: + ${escape(news.title)} + % endif + +
    ${news.content | n}
    +
    + + Назад к списку +
    + + diff --git a/lms/templates/news/form.html b/lms/templates/news/form.html new file mode 100644 index 000000000000..4bbcfda69a4d --- /dev/null +++ b/lms/templates/news/form.html @@ -0,0 +1,61 @@ + + + + Добавить новость + + + +

    Создание новости

    +
    + + +
    + + +
    + +
    + + +
    + +
    + + +
    + + +
    + Назад к списку + + diff --git a/lms/templates/news/list.html b/lms/templates/news/list.html new file mode 100644 index 000000000000..218569741f33 --- /dev/null +++ b/lms/templates/news/list.html @@ -0,0 +1,255 @@ +<%page expression_filter="h"/> +<%! from django.utils.translation import gettext as _ %> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("News")} + +<%block name="extrahead"> + + + +
    + + + % if news_list: +
    + % for news in news_list: +
    + % if news.image: + ${news.title} + % else: +
    + +
    + % endif + +
    + + +
    + ${news.created_at.date()} +
    + +
    + ${news.content[:100]}... +
    + + + ${_("Read more")} → + +
    +
    + % endfor +
    + % else: +
    +
    📰
    +

    ${_("No news available")}

    +

    ${_("Check back later for updates")}

    +
    + % endif +
    + +<%block name="extrajs"> + + diff --git a/lms/templates/static_templates/about.html b/lms/templates/static_templates/about.html index dae42e36dedb..128eef771e21 100644 --- a/lms/templates/static_templates/about.html +++ b/lms/templates/static_templates/about.html @@ -4,13 +4,549 @@ <%block name="pagetitle">${_("About")} -
    -
    -

    - <%block name="pageheader">${page_header or _("About")} -

    -

    - <%block name="pagecontent">${page_content or _("This page left intentionally blank. Feel free to add your own content.")} -

    + + + + + + + + + + +
    +
    +

    О нас - Open Kaznu

    +

    Казахский национальный университет им. аль-Фараби

    +
    -
    + +
    + +
    +
    +

    О платформе

    +
    + +
    +

    Казахский национальный университет им. аль-Фараби является лидером среди казахстанских университетов по внедрению MOOC на открытой платформе OpenEdx.

    + +

    С 2014-2015 учебного года Центр дистанционного образования КазНУ им. аль-Фараби совместно с преподавательским составом начал работу по созданию MOOC, и в настоящее время на http://open.kaznu.kz существует собственная платформа MOOC на основе системы Open edX.

    +
    + +
    +

    История развития

    +

    1 октября 2015 года были запущены первые открытые курсы от ведущих преподавателей КазНУ им. аль-Фараби - "Теория вероятностей" и "Физические задачи" с доцентом В. Кашкаровым, на которые записались около 250 и 500 студентов из разных регионов Казахстана соответственно.

    +
    + +
    +

    Эти курсы в основном посещали студенты 1-2 курсов КазНУ им. аль-Фараби, старшие классы Назарбаев интеллектуальных школ, профильные физико-математические и средние школы. Анализ данных показал большой интерес к этим курсам, что дает стимул преподавательскому составу и сотрудникам университета продолжать работу в этом направлении. К концу июня 2016 года в системе было зарегистрировано более двух тысяч слушателей.

    + +

    При активном содействии КазНУ им. аль-Фараби была запущена Национальная платформа открытого образования Казахстана (далее - НПОК).

    +
    +
    + + +
    +
    +

    Этапы развития

    +
    + +
    +
    +
    + 2014-2015 +

    Начало работы

    +

    Центр дистанционного образования КазНУ начинает работу по созданию MOOC

    +
    +
    + +
    +
    + Октябрь 2015 +

    Первый запуск

    +

    Запущены первые открытые курсы от ведущих преподавателей университета

    +
    +
    + +
    +
    + Январь 2016 +

    Реорганизация

    +

    Центр дистанционного образования преобразован в Институт дистанционного образования

    +
    +
    + +
    +
    + Февраль 2016 +

    Новый этап

    +

    Институт дистанционного образования преобразован в Институт новых образовательных технологий

    +
    +
    + +
    +
    + Июнь 2016 +

    Достижение

    +

    В системе зарегистрировано более двух тысяч слушателей

    +
    +
    +
    +
    + + +
    +
    +

    Институциональное развитие

    +

    В рамках реализации комплекса мер по развитию электронного обучения и организации работы НПОК для подготовки кадров на основе технологий дистанционного обучения (ДОТ) и усиления работы Центра дистанционного образования (ЦДО), с целью повышения мобильности предоставления образовательных услуг университета по новым образовательным программам, руководство университета приняло решение (приказ ректора №45 от 29.01.2016) о преобразовании структурного подразделения Центра дистанционного образования в Институт дистанционного образования (ИДО), в который вошел Центр массовых открытых онлайн-курсов (ЦМООК) в статусе проектного офиса национальной платформы.

    + +

    Были разработаны и утверждены вице-ректором по учебной работе Ахмет-Заки Д.Ж. Положение о ЦМООК, а также должностные инструкции руководителя, ведущего специалиста и специалиста Центра ЦМООК (от 14 марта 2016 г.).

    + +

    В феврале 2016 года Институт дистанционного образования был преобразован в Институт новых образовательных технологий, который продолжает работу ЦМООК.

    +
    +
    + + +
    +
    +

    Наша команда

    +
    + +
    +

    Руководство офиса

    +
    +
    +
    + +
    +

    Мусинова Асель

    +

    Заместитель директора Департамента по академическим вопросам

    +
    + +
    +
    + +
    +

    Круговых Илья

    +

    Руководитель Офиса академических и цифровых инноваций

    +
    +
    +
    + +
    +

    Специалисты open.kaznu.kz

    +
    +
    +
    + +
    +

    Арыстанова Айнур

    +

    Главный специалист

    +
    + +
    +
    + +
    +

    Канафина Диана

    +

    Ведущий специалист

    +
    + +
    +
    + +
    +

    Абылкасым Ерарыс

    +

    Умный-программист

    +
    +
    +
    + +
    +

    Специалисты dl.kaznu.kz

    +
    +
    +
    + +
    +

    Смагулова Шынар

    +

    Главный специалист

    +
    + +
    +
    + +
    +

    Малик Нурлы

    +

    Главный специалист

    +
    + +
    +
    + +
    +

    Жабаев Талгат

    +

    Инженер-программист

    +
    +
    +
    + +
    +

    Видеоинженер офиса

    +
    +
    +
    + +
    +

    Асылхан Аниятолла

    +

    Главный специалист

    +
    +
    +
    +
    +
    + + + + diff --git a/lms/templates/static_templates/author.html b/lms/templates/static_templates/author.html new file mode 100644 index 000000000000..7781c897fe65 --- /dev/null +++ b/lms/templates/static_templates/author.html @@ -0,0 +1,603 @@ +<%page expression_filter="h"/> +<%! from django.utils.translation import gettext as _ %> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("For authors")} + + + + + + + Создание онлайн-курса + + + + + + +
    +
    +

    ${_("Creating an Online Course for Authors")}

    +

    ${_("Al-Farabi Kazakh National University")}

    +
    +
    + +
    + +
    +
    +

    ${_("Methodological Requirements")}

    +
    + +
    +

    ${_("Requirements for Online Course Design")}

    + +
    +
    +
    + ${_("Electronic UMKD (e-UMKD)")} + +
    +
    +
      +
    • ${_("Electronic syllabus")}
    • +
    • ${_("Announcements for students – 1 per module")}
    • +
    • ${_("Questionnaires at the beginning and end of the course")}
    • +
    • ${_("Lecture notes with a list of sources – 1 per topic")}
    • +
    • ${_("Presentation – 1 per topic")}
    • +
    • ${_("Additional course materials with active links for online reading and viewing")}
    • +
    • ${_("Study questions – at least 10 per module")}
    • +
    • ${_("Discussion questions – at least 1 per module")}
    • +
    • ${_("Test tasks – at least 3 practice and 10 graded per module")}
    • +
    • ${_("Peer review assignments – no more than 1–2 per course")}
    • +
    +
    +
    + +
    +
    + ${_("Academic Presentation of the Online Course")} + +
    +
    +
      +
    • ${_("Measurable learning objectives")}
    • +
    • ${_("Expected Learning Outcomes (LO)")}
    • +
    • ${_("Indicators of LO achievement")}
    • +
    • ${_("Course language – student-oriented, accessible to a diverse audience")}
    • +
    • ${_("Course duration – at least 4 and no more than 15 weeks")}
    • +
    • ${_("Student workload per week – no less than 2 and no more than 5 hours")}
    • +
    +
    +
    +
    +
    +
    + + +
    +
    +

    ${_("Technical Requirements for Video")}

    +
    + +
    +

    ${_("Video recording must be carried out with professional equipment with track parameters not lower than the following:")}

    + +
    +
    +
    + ${_("Video")} + +
    +
    +

    ${_("Timing")}

    +
      +
    • ${_("Welcome/Promo video – no more than 3 minutes")}
    • +
    • ${_("Course introduction (purpose, objectives, structure, assignments) – 3 minutes")}
    • +
    • ${_("Video lecture – 3–9 minutes")}
    • +
    • ${_("Weekly video duration – at least 15 and no more than 30 minutes")}
    • +
    + +

    ${_("Technical Requirements")}

    +
      +
    • ${_("File format (container) – MP4")}
    • +
    • ${_("Codec – H264")}
    • +
    • ${_("Resolution: 1920 x 1080 (1080p)")}
    • +
    • ${_("Aspect ratio – 16:9")}
    • +
    • ${_("Frame rate – 25 or 30 fps")}
    • +
    • ${_("Progressive scan (25p/30p)")}
    • +
    • ${_("Bitrate – not less than 10,000 kbps and not more than 30,000 kbps")}
    • +
    +
    +
    + +
    +
    + ${_("Audio")} + +
    +
    +
      +
    • ${_("Codec: AAC, AC3, OGG, mp3")}
    • +
    • ${_("Channels: 2 (stereo)")}
    • +
    • ${_("Sample rate: 48 kHz")}
    • +
    • ${_("Audio stream: CBR not less than 192 kbps, VBR 160–320 kbps")}
    • +
    +
    +
    + +
    +
    + ${_("Audio Quality Characteristics")} + +
    +
    +
      +
    • ${_("Audio track must be true stereo, with the lecturer’s voice localized strictly between the left and right channels")}
    • +
    • ${_("Stereo track must be playable on monophonic equipment")}
    • +
    • ${_("Signal-to-noise ratio – at least 40 dB")}
    • +
    • ${_("Dynamic range of useful signal – no more than 16 dB")}
    • +
    • ${_("Average RMS loudness – from -14 dB to -12 dB")}
    • +
    • ${_("Peak loudness – limited to -2 dB")}
    • +
    +
    +
    + +
    +
    + ${_("Presentation Design Quality for Video")} + +
    +
    +
      +
    • ${_("Sans-serif font is recommended")}
    • +
    • ${_("No more than 2 fonts per course")}
    • +
    • ${_("Bullet points must be consistent throughout the course")}
    • +
    • ${_("Use contrasting text and background colors for readability")}
    • +
    • ${_("No more than 3 font colors per course")}
    • +
    • ${_("Avoid mixing contrasting colors within one sentence, paragraph, or table")}
    • +
    • ${_("Line thickness for borders, tables, and arrows should match font line thickness")}
    • +
    • ${_("Use photos, drawings, and animations in a consistent color scheme")}
    • +
    • ${_("Icons and infographics are recommended")}
    • +
    • ${_("Avoid merging same-sized scenes for natural perception")}
    • +
    • ${_("Avoid distracting clothing and makeup during recording")}
    • +
    • ${_("Slides: heading font – 20 pt, body text – 18 pt")}
    • +
    • ${_("Image size – at least 1280x720p (HD)")}
    • +
    • ${_("Image format – PNG")}
    • +
    • ${_("All external materials must have a CC Attribution license")}
    • +
    • ${_("Each borrowed image must include a source link")}
    • +
    +
    +
    +
    +
    +
    + + +
    +
    +

    ${_("Stages of Online Course Development")}

    +
    + +
    +
    +
    +
    + +
    +

    ${_("Application Submission")}

    +

    ${_("Submission of an application for online course development. Participation in the competition of pedagogical scenarios for creating and publishing online courses (MOOC/SPOC).")}

    +
    +
    + +
    +
    +
    + +
    +

    ${_("Course Development")}

    +

    ${_("Start of the online course development process.")}

    +
    +
    + +
    +
    +
    + +
    +

    ${_("Preparation of Materials")}

    +

    ${_("Preparation of online course materials by the author-developer. For documents on requirements and recommendations for recording video materials, please contact the Center for Massive Open Online Courses.")}

    +
    +
    + +
    +
    +
    + +
    +

    ${_("Material Expertise")}

    +

    ${_("Methodological and technical expertise of online course materials.")}

    +
    +
    + +
    +
    +
    + +
    +

    ${_("Video Recording")}

    +

    ${_("Recording of online course video materials.")}

    +
    +
    + +
    +
    +
    + +
    +

    ${_("Material Upload")}

    +

    ${_("Uploading online course materials to the platform.")}

    +
    +
    + +
    +
    +
    + +
    +

    ${_("Testing and Expertise")}

    +

    ${_("Beta testing and course expertise.")}

    +
    +
    +
    +
    + + +
    +

    ${_("Contacts")}

    +

    ${_("For all inquiries, please contact the Office of Academic and Digital Innovations: Building - Rectorate, Office 207, phone")} +7 727 377 33 30 ${_("(ext.")} 31-24, 11-33, 16-47)

    +
    +
    + + + + + + diff --git a/lms/templates/static_templates/catalog_transfer.html b/lms/templates/static_templates/catalog_transfer.html new file mode 100644 index 000000000000..c6ddbd95daf6 --- /dev/null +++ b/lms/templates/static_templates/catalog_transfer.html @@ -0,0 +1,1760 @@ +<%page expression_filter="h"/> +<%! from django.utils.translation import gettext as _ %> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Catalog Mook")} + + + + + Каталог МООК КазНУ + + + + + + +

    Каталог МООК КазНУ им. Аль-Фараби

    + +
    + + + + + + +
    + +
    + +
    +
    + + +
    +

    Methods of molecular biology

    +

    Тип курса: SPOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 17.0

    +

    Учебная нагрузка (кредиты): 0.5

    +

    Область образования: 6B02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6B02207-востоковедения

    +

    Описание курса: ...

    +

    Осваиваемые навыки: ...

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Базовый восточный язык С1

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть + курс

    +

    Организация: Sungkyunkwan University

    +
    +
    +

    Arabic for Beginners: Communicating in Arabic Culture

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 17.0

    +

    Учебная нагрузка (кредиты): 0.5

    +

    Область образования: 6B02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 7M022 Гуманитарные науки

    +

    Код и название образовательной программы: 7M03115-Регионоведение (восточные страны)

    + +

    Описание курса: Araic for Beginners: Communicating in Arabic Culture — курс Khalifa University + (государственный исследовательский университет, Абу-Даби, ОАЭ). + Курс является вторым из трёх в специализации по начальному арабскому языку. + Содержит погружение в лексику образования и школьной среды, полезен для общения + в международной рабочей среде, путешествий и финансовых ситуаций. + Подчёркивает важность коммуникации в технологически развитом глобальном обществе. + Особое внимание уделено пониманию частей тела в спортивном и медицинском контексте + и как они используются как физические и эмоциональные выражения в арабской культуре. + Основная информация: курс 2/3 в специализации, уровень Intermediate, 4 недели обучения по 2–3 часа в + неделю.

    + +

    Осваиваемые навыки: Повседневные фразы межличностного общения в личной и профессиональной среде.

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Восточный язык международного общения (Арабский)

    +

    Учебная нагрузка в кредитах: 9.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: Khalifa University (государственный исследовательский университет, Абу-Даби, ОАЭ)

    +
    +
    +

    Экономическое развитие Кореи

    +

    Тип курса: SPOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 17.0

    +

    Учебная нагрузка (кредиты): 0.5

    +

    Область образования: 6B02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6B02207-востоковедения

    +

    Описание курса: Сформировать способность анализировать политические и социально-экономические положения + изучаемой страны для определения основных комплексных факторов современного исторического процесса.

    +

    Осваиваемые навыки: Дисциплина направлена на изучение теоретической и методологической основы факторов + исторических процессов; их взаимосвязи с современной политической модернизацией и экономическим + развитием, влияние на культурное изменение и общественно-политической жизни изучаемой страны + Востока.

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Современные исторические процессы в изучаемой страны

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс +

    +

    Организация: Yonsei University

    +
    +
    +

    A Bridge to the World: Korean Language for Advanced I

    +

    Тип курса: SPOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 17.0

    +

    Учебная нагрузка (кредиты): 0.5

    +

    Область образования: 6B02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6B02207-востоковедения

    +

    Описание курса: Цель дисциплины cформировать способность определять основную идею в незнакомом тексте; + применять в речи более сложные лексико-грамматические темы, конструкции, фразеологизмы, чем на + предыдущем уровне; осуществлять письменный перевод с соблюдением норм лексической эквивалентности.

    +

    Осваиваемые навыки: Дисциплина направлена на повышение культуры речи; пополнение лексического запаса при + изучении сложных текстов различной тематики; расширение профессионального кругозора.

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Базовый восточный язык С1

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть + курс

    +

    Организация: Sungkyunkwan University

    +
    + +
    +

    Global Diplomacy – Diplomacy in the Modern World

    +

    Тип курса: SPOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 17.0

    +

    Учебная нагрузка (кредиты): 0.5

    +

    Область образования: 6B02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6B02207-востоковедения

    +

    Описание курса: Цель дисциплины — сформировать способность понимать содержание понятий, подходов, + используемых мировой наукой в изучении роли глобализации. Курс формирует знания об основных трактовках + понятия глобализации, факторах влияния на глобальное развитие.

    +

    Осваиваемые навыки: Дисциплина направлена на изучение ключевых направлений влияния глобализации на + дипломатию в мировой политике, процесса внедрения глобальных тенденций в развитие международных + отношений.

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Глобальная дипломатия в современном мире

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of London

    +
    +
    +

    Структурирование ценностей в современном Китае

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 28.91

    +

    Учебная нагрузка (кредиты): 0.964

    +

    Область образования: 8D02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6В02207-Востоковедение

    +

    Описание курса: Эта последовательность из четырех курсов предложит междисциплинарный подход к изучению + истории китайской культуры, рассматриваемой как последовательность режимов рациональности (философского, + бюрократического и экономического). Основное внимание будет уделено моментам смены парадигмы от одного + режима рациональности к другому. Для каждого из этих моментов культурные факты и артефакты — мысли, + литература, ритуалы — будут рассматриваться в связи с изменением социальных, политических и + экономических систем.

    +

    Осваиваемые навыки: Слушатели смогут рассматривать культурные ценности в связи с изменением социальных, + политических и экономических систем.

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Внешняя политика изучаемой страны Востока

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть + курс

    +

    Организация: The Chinese University of Hong Kong

    +
    + +
    +

    Подходы социальных наук к изучению китайского общества Часть 1

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 15.66

    +

    Учебная нагрузка (кредиты): 0.522

    +

    Область образования: 8D02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6В02207-Востоковедение

    +

    Описание курса: Цель этого курса — превратить обучающихся в информированных потребителей исследований в + области социальных наук. Курс знакомит заинтересованных неспециалистов с концепциями, стандартами и + принципами проведения исследований в области социальных наук.

    +

    Осваиваемые навыки: могут оценивать доказательства и критически оценивать утверждения о важных социальных + явлениях

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Государственно-политический строй Китая

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: The Hong Kong University of Science and Technology

    +
    +
    +

    Successful Negotiation: Essential Strategies and Skills

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 17.0

    +

    Учебная нагрузка (кредиты): 5.0

    +

    Область образования: 6B02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6B02207-Востоковедение

    +

    Описание курса: Successful Negotiation: Essential Strategies and Skills

    +

    Осваиваемые навыки: Этот модуль фокусируется на анализ переговоров, понимания стратегии и навыков ведения + переговоров

    +

    Язык курса: английский/русский

    +

    Дисциплины для перезачета: YaDEISV 4307 Язык дипломатии и этикета изучаемой стран Востока

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of Michigan

    +
    + +
    +

    Management Foundations in the Hospitality Industry

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 30.0

    +

    Учебная нагрузка (кредиты): 2.0

    +

    Область образования: 8D11 Услуги

    +

    Область профессиональной подготовки: 8D111 Сфера обслуживания

    +

    Код и название образовательной программы: 8D11102 Туризм и гостеприимство

    +

    Описание курса: Этот курс представляет собой введение в мотивацию, лидерство, коммуникации, принятие + решений и руководство людьми посредством эффективного управления человеческими ресурсами (HR), этику, + социальную ответственность и управление потребительским опытом в индустрии гостеприимства путем изучения + основ менеджмента, ориентированного на обслуживание. Все необходимые материалы для чтения представлены в + модулях курса. Они включают внешние ссылки, статьи, графику и видео. Курс состоит из следующих + элементов: тематические видеоматериалы, обсуждения и тесты для проверки понимания.

    +

    Осваиваемые навыки: Понимать, как HR-отделы анализируют распределение ресурсов по рабочим местам и + факторы, влияющие на разработку рабочих мест; определять наиболее эффективные методы найма талантливых + сотрудников; применять лучшие практики при проведении собеседований; оценивать программу обучения и + социализации сотрудников; понимать оценку результатов работы; избегать юридической ответственности за + "небрежный прием на работу".

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Разработка инновационных решений для менеджмента в сфере туризма и + гостеприимства

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс +

    +

    Организация: University of North Texas

    +
    + +
    +

    Foundations of Hotel Budgeting and Forecasting

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 45.0

    +

    Учебная нагрузка (кредиты): 3.0

    +

    Область образования: 7M11 Услуги

    +

    Область профессиональной подготовки: 8D111 Сфера обслуживания

    +

    Код и название образовательной программы: 7M11104 - Ресторанное дело и гостиничный бизнес

    +

    Описание курса: Курс предназначен для обучения основным навыкам и знаниям бюджетирования и + прогнозирования в гостиничном бизнесе. Он предоставляет всестороннее понимание фундаментальных + концепций, практик и инструментов составления бюджета и прогнозирования, включая практические упражнения + и примеры из реальной жизни для применения знаний в индустрии гостеприимства.

    +

    Осваиваемые навыки: Составление бюджета отелей, оценка финансовых показателей, понимание тенденций в + отрасли, разработка стратегий на основе финансовых данных, практическое применение знаний в гостиничном + бизнесе.

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Прогнозирование и планирование в сфере гостеприимства

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: Starweaver

    +
    +
    +

    Introduction to Process Safety and Risk Analysis

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 13.01

    +

    Учебная нагрузка (кредиты): 0.434

    +

    Область образования: 6B05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 6B052 Окружающая среда

    +

    Код и название образовательной программы: 6В05202 Экология

    +

    Описание курса: Каждый курс включает задание на рецензирование, в котором будут использованы знания, + полученные при изучении основополагающих концепций, аутентичных примеров из мировой и американской + практики, а также методов применения. Обратная связь в процессе экспертной оценки очень важна; учащимся, + а также нынешним и будущим специалистам в области общественного здравоохранения придется давать + критические отзывы.

    +

    Осваиваемые навыки: Environmental justice, Public Health

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Қоршаған ортаға әсерін бағалау және экологиялық сараптама

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of California, Davis

    +
    +
    +

    Impacts of the Environment on Global Public Health Specialization

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 115.0

    +

    Учебная нагрузка (кредиты): 3.85

    +

    Область образования: 6B05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 6B052 Окружающая среда

    +

    Код и название образовательной программы: 6B11202- Экологическая инженерия

    +

    Описание курса: Каждый курс включает задание на рецензирование, в котором будут использованы знания, + полученные при изучении основополагающих концепций, аутентичных примеров из мировой и американской + практики, а также методов применения. Обратная связь в процессе экспертной оценки очень важна; учащимся, + а также нынешним и будущим специалистам в области общественного здравоохранения придется давать + критические отзывы.

    +

    Осваиваемые навыки: Environmental justice, Public Health

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Қоршаған ортаға әсерін бағалау және экологиялық сараптама

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of California, Davis

    +
    +
    +

    Экология: Динамика экосистем и их сохранение

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 13.01

    +

    Учебная нагрузка (кредиты): 0.434

    +

    Область образования: 6B05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 6B052 Окружающая среда

    +

    Код и название образовательной программы: 6В05202 Экология

    +

    Описание курса: Этот курс представляет собой введение в экологию и динамику экосистем с использованием + системного мышления. На примере национального парка Горонгоза в Мозамбике слушатели изучат, как ученые + изучают экосистемы, и исследуют сложный комплекс факторов, которые лежат в основе управленческих усилий. + По окончании курса слушатели смогут решать реальные вопросы, связанные с охраной природы, например, + смогут ли экосистемы восстановиться после антропогенного воздействия и какую роль в этом восстановлении + могут и должны играть люди.

    +

    Осваиваемые навыки: В конце курса учащиеся смогут решать реальные вопросы охраны природы, например, может + ли экосистема восстановиться после антропогенного нарушения и какую роль люди могут и должны играть в + этом восстановлении.

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Экологический риск

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть + курс

    +

    Организация: American Museum of Natural History

    +
    +
    +

    Глобальный экологический менеджмент

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 18.79

    +

    Учебная нагрузка (кредиты): 0.626

    +

    Область образования: 6B05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 6B052 Окружающая среда

    +

    Код и название образовательной программы: 6В05202 Экология

    +

    Описание курса: Узнайте о лучших экологических технологиях для устойчивого развития и о том, как ими + управляют в различных условиях по всему миру. Этот курс дает Вам возможность узнать о глобальных + тенденциях, влияющих на нашу окружающую среду и условия жизни, а также о том, как различные системы и + подходы к управлению окружающей средой, применяемые во всем мире, позволяют управлять ею. Сюда входят + современные технологии, созданные для охраны окружающей среды, и технологии устойчивого управления + почвой, методы защиты грунтовых вод и интегрированное управление водными ресурсами.

    +

    Осваиваемые навыки: Городское планирование, Развитие водных ресурсов, Природные ресурсы, Зеленые + технологии

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Стандарты менеджмента окружающей среды

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть + курс

    +

    Организация: Technical University of Denmark (DTU)

    +
    +
    +

    Введение в экологическое законодательство и политику

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 17.35

    +

    Учебная нагрузка (кредиты): 0.578

    +

    Область образования: 6B05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 6B052 Окружающая среда

    +

    Код и название образовательной программы: 6B11202- Экологическая инженерия

    +

    Описание курса: Экологическое право может быть единственным институтом, стоящим между нами и планетарным + истощением...

    +

    Осваиваемые навыки: Экологическое право, Закон, Защита окружающей среды

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Экологическое законодательство и политика

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть + курс

    +

    Организация: The University of North Carolina at Chapel Hill

    +
    + +
    +

    Азиатские экологические гуманитарные науки: Ландшафты в переходный период

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 17.35

    +

    Учебная нагрузка (кредиты): 0.578

    +

    Область образования: 6B05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 6B052 Окружающая среда

    +

    Код и название образовательной программы: 8D05205 Геоэкология и управление природопользованием

    +

    Описание курса: В этом курсе... (краткое описание можно расширить)

    +

    Осваиваемые навыки: анализ ландшафтов, экологическая гуманитаристика, устойчивость

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Техногенез и формирование природно-техногенных ландшафтов

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть + курс

    +

    Организация: University of Zurich

    +
    + + + + + + + + + +
    +

    Introduction to the Arctic: Climate

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 11.57

    +

    Учебная нагрузка (кредиты): 0.386

    +

    Область образования: 6B05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 8D052 Окружающая среда

    +

    Код и название образовательной программы: 7М05204 - Метеорология

    +

    Описание курса: курс, посвященный изучению окружающей среды и климата циркумполярного Севера.

    +

    Осваиваемые навыки: Оценинть влияние Арктики на климат и экосистемы. Связь Арктики с остальным миром. Современное изменение климата, процессы, вызывающие его, и свидетельства его наличия в Арктике, а также последствия для быстро развивающегося Севера.

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Долгосрочные прогнозы погоды

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: The University of Alberta, the University of Tromso and the University of the Arctic

    +
    + +
    +

    Climate change education: sustainable environments

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 10.12

    +

    Учебная нагрузка (кредиты): 0.337

    +

    Область образования: 7M05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 8D052 Окружающая среда

    +

    Код и название образовательной программы: 6В05204 - Метеорология

    +

    Описание курса: В этом курсе мы изучаем компоненты ландшафтов и стихийные бедствия. Мы делаем акцент на действиях человека, которые влияют на окружающую среду, как с точки зрения изменения, так и сохранения. Кроме того, мы изучаем концепции, связанные с городами, мегаполисами и устойчивой средой. Курс также углубляется в связь между окружающей средой, здоровьем и болезнями»

    +

    Осваиваемые навыки: "Определение элементов, составляющие ландшафт, стихийные бедствия и действия человека, способствующие их изменению. Влияние городов, потребления и нашего экологического следа на окружающую среду. Влияние изменения климата на здоровье и действия, которые необходимо предпринять для создания более здоровой окружающей среды."

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Прогноз стихийных бедствий

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: Banco Interamericano de Desarrollo

    +
    + +
    +

    How Do We Manage Climate Change?

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 8.67

    +

    Учебная нагрузка (кредиты): 0.289

    +

    Область образования: 6B05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 8D052 Окружающая среда

    +

    Код и название образовательной программы: 6В05204 - Метеорология

    +

    Описание курса: В этом курсе слушатели определят типы действий, которые мы можем предпринять для решения проблемы изменения климата. Эти действия делятся на две большие категории: 1) смягчение последствий, которое относится к усилиям по снижению выбросов парниковых газов или увеличению поглотителей углерода, и 2) адаптация, которая относится к нашей подготовке к климатическим воздействиям. Мы изучим технологии, программы и политику, связанные как со смягчением, так и с адаптацией. По окончании курса слушатели должны научиться определять и оценивать действия, предпринимаемые сообществами, правительствами и предприятиями в области климата.

    +

    Осваиваемые навыки: "Объясните общие цели политики смягчения последствий изменения климата и адаптации к ним Опишите основные источники и последние тенденции в области выбросов парниковых газов Объясните концепцию климатического риска и опишите его составляющие Примеры политики смягчения последствий и усилий по адаптации "

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Климат Казахстана, Климатология

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of Colorado Boulder

    +
    + +
    +

    Act on Climate: Steps to Individual, Community, and Political Action

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 36.14

    +

    Учебная нагрузка (кредиты): 1.205

    +

    Область образования: 8D05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 8D052 Окружающая среда

    +

    Код и название образовательной программы: 8D05204 - Метеорология

    +

    Описание курса: Курс фокусируется на том, как перевести обучение в плоскость действий по борьбе с изменением климата в таких сферах, как продовольствие, энергетика, транспорт и построенная среда (города).

    +

    Осваиваемые навыки: В результате прохождения этого курса вы сможете: 1) Определить индивидуальные, общественные и политические действия, которые вы можете предпринять для эффективного решения проблемы изменения климата и реагирования на нее. 2) Описывать, как можно использовать достижения социальных наук для создания изменений на индивидуальном, общественном и политическом уровнях. 3) Чувствовать себя в состоянии продолжать влиять на то, как вы, ваше сообщество и политические лидеры решают проблемы изменения климата и реагируют на них. Используйте #UMichActonClimate в социальных сетях, чтобы делиться своими достижениями и общаться с другими учениками.

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Динамика климата

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of Michigan

    +
    + +
    +

    Our Energy Future

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 52.05

    +

    Учебная нагрузка (кредиты): 1.735

    +

    Область образования: 8D05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 8D052 Окружающая среда

    +

    Код и название образовательной программы: 8D05204 - Метеорология

    +

    Описание курса: Этот курс предназначен для ознакомления студентов с проблемами энергетики в XXI веке, включая продовольствие и топливо, которые неразрывно связаны между собой. В рамках курса будут обсуждаться вопросы производства и использования энергии с точки зрения биологии, инженерии, экономики, климатологии и социальных наук. Этот курс будет посвящен современному производству и использованию энергии, а также последствиям этого использования, рассмотрению конечных запасов ископаемых энергоносителей, взаимосвязи продовольствия и энергии, воздействию на окружающую среду и климат, а также социальным и экономическим последствиям нашего современного производства и использования энергии и продовольствия.

    +

    Осваиваемые навыки: Вопросы производства и использования энергии с точки зрения биологии, инженерии, экономики, климатологии и социальных наук. Понимание современного производства и использованию энергии, а также последствия этого использования, рассмотрение конечных запасов ископаемых энергоносителей, взаимосвязи продовольствия и энергии, воздействию на окружающую среду и климат, а также социальные и экономические последствиям нашего современного производства и использования энергии и продовольствия.

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Доступный потенциал альтернативный источников энергии

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of California San Diego

    +
    + +
    +

    Remote Sensing Image Acquisition, Analysis and Applications

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 47.71

    +

    Учебная нагрузка (кредиты): 1.59

    +

    Область образования: 7M07 Инженерные, обрабатывающие и строительные отрасли

    +

    Область профессиональной подготовки: 7M073 Архитектура и строительство

    +

    Код и название образовательной программы: 7M07302-Геоинформатика

    +

    Описание курса: "Этот курс охватывает фундаментальную природу дистанционного зондирования, а также используемые платформы и типы датчиков. Он также обеспечивает углубленное изучение вычислительных алгоритмов, используемых для понимания изображений, начиная от самых ранних исторически важных методов до более современных подходов, основанных на глубоком обучении. Материал курса широко проиллюстрирован примерами и комментариями о том, как технология применяется на практике. Он подготовит участников к использованию материала в своих собственных дисциплинах и проведению более подробного изучения дистанционного зондирования и связанных тем."

    +

    Осваиваемые навыки: "Радарные системы Дистанционное зондирование Машинное обучение Анализ изображений"

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Дистанционное зондирование окружающей среды

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: UNSW Sydney (The University of New South Wales)

    +
    + +
    +

    Ақпаратты визуалдау: жетілдірілген әдістер

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 15.6

    +

    Учебная нагрузка (кредиты): 1.0

    +

    Область образования: 6B05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 6B052 Окружающая среда

    +

    Код и название образовательной программы: 6В05205-География

    +

    Описание курса: "Бұл курстың мақсаты – оқушыларды «Ақпаратты визуалдау: Негізгі қағидаттар» бөлімінде сипатталған негізгі диаграммалардан тыс визуалдаудың озық әдістерімен таныстыру. Берілген әдістер уақыт пен кеңістіктегі деректерді, желілер мен дарақтарды, сондай-ақ мәтіндік деректерді өңдеудің озық тәжірибелерін қамту мақсатында деректердің белгілі бір түрлеріне негізделген. Бұл модульде біз білім алушыларға D3.js-те инновациялық әдістерді қалай жасау керектігін үйретеміз. Оқу мақсаттары Мақсат: деректерді визуалдаудың әралуан түрлері үшін визуалдау шешімдерінің жобалық кеңістігін талдау. Берілген мәселені шешу үшін қандай құрастырымдар қолжетімді және олардың қандай артықшылықтары мен кемшіліктері бар екенін білу. - Уақыт - Кеңістік - Уақыт және кеңістік - Жүйе - Ағаш диаграммасы - Мәтін Бұл - «Ақпаратты визуалдау» мамандығы бойынша төртінші курс. Курс сізден бағдарламалау бойынша негізгі білім, сондай-ақ кейбір негізгі визуалдау дағдыларының (мамандандырудың бірінші курсында көрсетілген дағдыларға ұқсас) болуын талап етеді."

    +

    Осваиваемые навыки: Адамның компьютерлік өзара әрекеттесуі, кеңістіктік деректерді талдау, компьютерлік графика, интерактивті дизайн, сюжетті (графика), геовизуализация, статистикалық визуализация, деректерді визуализациялау

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Методы визуализации в географических исследованиях

    +

    Учебная нагрузка в кредитах: 9.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of Flotida

    +
    + +
    +

    Sustainable Agricultural Land Management

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 11.8

    +

    Учебная нагрузка (кредиты): 0.7

    +

    Область образования: 6B07 Инженерные, обрабатывающие и строительные отрасли

    +

    Область профессиональной подготовки: 6B073 Архитектура и строительство

    +

    Код и название образовательной программы: 6В07303-Землеустройство

    +

    Описание курса: This course will cover the agricultural and urban water quality issues in Florida, their bases, land and nutrient management strategies, and the science and policy behind the best management practices (BMPs). Students will learn to evaluate BMP research and analyze its role in determining practices and policies that protect water quality.

    +

    Осваиваемые навыки: Resource, Sustainability, Agriculture, Nutrients

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Внутрихозяйственное и региональное землеустройство

    +

    Учебная нагрузка в кредитах: 9.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of Florida

    +
    + +
    +

    A Scientific Approach to Innovation Management

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 18.79

    +

    Учебная нагрузка (кредиты): 0.626

    +

    Область образования: 8D05 Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 8D052 Окружающая среда

    +

    Код и название образовательной программы: 8D05202-География, 8D07303 - Землеустройство, 8D07305-Кадастр

    +

    Описание курса: How can innovators understand if their idea is worth developing and pursuing? In this course, we lay out a systematic process to make strategic decisions about innovative product or services that will help entrepreneurs, managers and innovators to avoid common pitfalls. We teach students to assess the feasibility of an innovative idea through problem-framing techniques and rigorous data analysis labelled ‘a scientific approach’. The course is highly interactive and includes exercises and real-world applications. We will also show the implications of a scientific approach to innovation management through a wide range of examples and case studies.

    +

    Осваиваемые навыки: Data Analysis, Decision Making, Probability & Statistics, Innovation, Leadership and Management

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Планирование диссертационного исследования и публикационной деятельности; Управление научными проектами в землеустройстве; Управление проектами пространственного развития

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: Università Bocconi

    +
    + +
    +

    Artificial Intelligence (AI) Education for Teachers

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 23.13

    +

    Учебная нагрузка (кредиты): 0.771

    +

    Область образования: 7M01 Педагогические науки

    +

    Область профессиональной подготовки: 7M014 Подготовка педагогов с предметной специализацией общего развития

    +

    Код и название образовательной программы: 7М01505-География

    +

    Описание курса: Сегодняшним учащимся необходимо знать, что такое искусственный интеллект (ИИ), как он работает, как использовать его в повседневной жизни и как он потенциально может быть использован в будущем. Использование ИИ требует навыков и ценностей, которые выходят далеко за рамки простого знания о программировании и технологиях. Этот курс разработан учителями для учителей и позволит преодолеть разрыв между общепринятыми представлениями об ИИ и тем, что это такое на самом деле. ИИ может быть внедрен во все области школьной программы, и этот курс покажет вам, как это сделать. \п\п Этот курс понравится учителям, которые хотят расширить свое общее понимание искусственного интеллекта, в том числе того, почему он важен для учащихся; и/или тем, кто хочет внедрить искусственный интеллект в свою преподавательскую практику и обучение своих учеников. Существует также уникальная возможность реализовать проект Capstone для студентов наряду с этим курсом профессионального обучения.Образовательная школа Маккуори при Университете Маккуори и IBM Australia совместно разработали этот курс, который соответствует австралийским профессиональным стандартам AITSL "Профессиональный уровень" на уровне AQF 8.

    +

    Осваиваемые навыки: "Машинное обучение Навыки мышления Дизайн-мышление Искусственный интеллект (AI)"

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Цифровые технологии в географическом образования

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: IBM, Macquarie University

    +
    + +
    +

    Writing Practice

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 52.047

    +

    Учебная нагрузка (кредиты): 1.735

    +

    Область образования: 6B02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6B02211-Этнология и антропология

    +

    Описание курса: "Welcome to the Writing Practices course. This course aims to prepare you for performing writing tasks in academic contexts. In this course you will be introduced to basic academic writing skills. You will learn how the principles and structures of academic writing work when you are composing a variety of scripts. This course covers a wide variety of topics related to academic writing that will prepare you for presenting arguments, describing scientific processes and comparing objects and ideas. Course Positioning  This course is an intermediate level course in academic writing, intended for learners who have basic proficiency in reading and writing in English. The knowledge gained from this course will help you write effectively in most academic situations. System Requirements for the Course  You would need a functional computer, a steady internet connection, a good browser, and access to Google Drive for this course."

    +

    Осваиваемые навыки: Writing, Proofreading, Report Writing, Research, Writing and Editing, Editing

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Scientific writing

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: Birla Institute of Technology & Science, Pilani

    +
    + +
    +

    Анатомия верхних и нижних конечностей

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 21.686

    +

    Учебная нагрузка (кредиты): 0.723

    +

    Область образования: 6B02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6B02210-Археология

    +

    Описание курса: Этот курс состоит из двух основных частей, одна из которых посвящена нижней конечности, а другая - верхней. Мы покажем, как различные системы, снабжающие и организующие конечность, контролируют ее функцию. Предусмотрен набор вводных лекций, которые позволят более опытным студентам освежить свои знания о конечностях и послужат руководством для тех, кто имеет меньший опыт. За этими лекциями последуют подробные разборы конечностей с акцентом на локомоции для нижних конечностей и на положение и функции рук для верхних конечностей. Этот курс является частью 2/4 в рамках специализации Йельского университета по анатомии человека.

    +

    Осваиваемые навыки: Anatomy

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Основы физической антропологии

    +

    Учебная нагрузка в кредитах: 6.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: Yale University

    +
    + +
    +

    Цифрлық дәуірдің интеллектуалдық құралдары

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 6.024

    +

    Учебная нагрузка (кредиты): 0.201

    +

    Область образования: 6B03 Социальные науки, журналистика и информация

    +

    Область профессиональной подготовки: 6B032 Журналистика и информация

    +

    Код и название образовательной программы: 6В03208-Цифровое архивоведение и документоведение

    +

    Описание курса: "Цифрлық технологиялар бүкіл әлемдегі бизнес, үкімет және қоғамды өзгерту үстінде. Олардың әлем экономикасының әрбір секторы үшін жаңа тәуекелдер мен мүмкіндіктер ашатыны анық. Мұндай “футурология” жалпы көріністі ұсына алады, ал сіз үшін цифрлық әлем нені білдіреді? Болашақтағы жасанды интеллект пен big data-ға негізделген, автоматтандырылған жұмысқа дайынсыз ба? Бұл курс осы сұрақтарға бірегей көзқараспен жауап береді: сізге интеллектуалдық анализ әлемін таныстырады. Ең алдымен, осы курста цифрлық дәуірде жоғары бағаланатын кейбір дағдылар айтылып, олардың неліктен құнды болатыны түсіндіріледі. Сондай-ақ курс цифрлық әлемді түсінуде когнитивтік артықшылық беру үшін сізге АҚШ интеллектуалдық қауымдастығы қалыптастырған менталдық модельдер мен практикалық құрылымдарды ұсынады. Бұл артықшылық тыңшылық пен құпияларға қатысты емес. Ойлау жүйесін жақсартуға қатысты. Бұл аналитикалық құралдар жинағы MBA стиліндегі стратегиялардың қажыған, статикалық құрылымдарынан мүлде бөлек. Оның орнына, бұл құралдар сізге цифрлық дәуірдегі құбылмалылық, белгісіздік, екіұштылық және алдампаздықты еңсеруге көмектеседі. Түптеп келгенде, осы курс әлем цифрландырылған сайын, келешектегі жетістік өзіңізге де, басқаларға да сұрақтарды жақсырақ қоюға көбірек тәуелді екенін дәлелдейді. Осылайша, ол сізді интеллектуалдық талдаушыға тән сұрақ қоюға негізделген ойлау жүйесін қабылдауға үйретеді. Қысқасы, әлдеқайда анық, құрылымдалған ойлау жүйесінің арқасында тұрақты бизнес артықшылыққа қол жеткізу үшін осы курсқа қатысыңыз."

    +

    Осваиваемые навыки: Зерттеу және дизайн, бизнес талдау, деректерді басқару, іскерлік интеллект, сыни ойлау, стратегия және операциялар, коммуникация, көшбасшылық және басқару, процестерді талдау, мәдениет, бейімделу, іскерлік психология, эмоционалдық интеллект, кәсіпкерлік, болжау, ынтымақтастық, инновация, ықтималдық және статистика , Шешім қабылдау, Деректерді талдау, Адам ресурстары, Маркетинг, Сатылым, Стратегия, Талдау, Адамның компьютерлік өзара әрекеттесуі, Адамдарды талдау

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Офисные информационные технологии

    +

    Учебная нагрузка в кредитах: 9.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: IE university

    +
    + +
    +

    Introduction to Ancient Egypt and Its Civilization

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 11.566

    +

    Учебная нагрузка (кредиты): 0.386

    +

    Область образования: 6B02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6В02212-История со знанием ин.яз.

    +

    Описание курса: Колоссальные пирамиды, величественные храмы, золотые сокровища, загадочные иероглифы, могущественные фараоны, странные боги и таинственные мумии - особенности культуры Древнего Египта, которые восхищали людей на протяжении тысячелетий. В Библии упоминаются ее боги, правители и пирамиды. Соседние культуры древнего Ближнего Востока и Средиземноморья писали о его богоподобных царях и кажущихся бесконечными запасах золота. Греки и римляне описывали отдельные аспекты культуры и истории Египта. В начале 19-го века наполеоновская кампания в Египте привлекла внимание к чудесам этой древней страны, и интерес общественности резко возрос. Вскоре после этого Шампольон расшифровал египетские иероглифы и открыл другим ученым путь к тому, что египетские тексты касались медицины, стоматологии, ветеринарной практики, математики, литературы, бухгалтерского учета и многих других тем. Затем, в начале 20-го века, Говард Картер обнаружил гробницу Тутанхамона и ее сказочное содержимое. Выставки этих сокровищ несколько десятилетий спустя привели к первому в мире блокбастеру, а их возрождение в 21 веке поддерживает интерес к ним. Присоединяйтесь к д-ру Дэвиду Сильверману, профессору египтологии в Пенне, куратору египетского отдела Пеннского музея и куратору выставок Тутанхамона, чтобы совершить экскурсию по загадкам и чудесам этой древней земли. Он разработал этот онлайновый курс и расположил его в галереях всемирно известного музея Пенна. Он использует множество оригинальных египетских артефактов для иллюстрации своих лекций, направляя студентов в процессе их самостоятельного знакомства с этой увлекательной культурой.

    +

    Осваиваемые навыки: знать особенности культуры Древнего Египта; уметь анализировать первоисточники по культуре и истории Египта. ; уметь оценивать отдельные аспекты культуры и истории Египта; ознакомиться со множествами оригинальными египетскими артефактами.

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Цивилизация древнего Востока

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of Pennsylvania

    +
    + +
    +

    Academic Information Seeking

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 8.675

    +

    Учебная нагрузка (кредиты): 0.289

    +

    Область образования: 6B03 Социальные науки, журналистика и информация

    +

    Область профессиональной подготовки: 6B032 Журналистика и информация

    +

    Код и название образовательной программы: 6В03208-Цифровое архивоведение и документоведение

    +

    Описание курса: "Этот курс познакомит Вас с основными элементами поиска академической информации - мы изучим процесс поиска, начиная с определения стратегии и заканчивая оценкой и документированием результатов поиска. Посещение этого курса сделает Вас опытным искателем информации. Вы научитесь проводить всесторонний поиск литературы на основе Вашего собственного исследовательского задания. Вы пройдете через различные этапы поиска информации, начиная с выбора соответствующих стратегий и методов поиска и заканчивая оценкой результатов поиска, документированием процесса поиска и цитированием своих источников"

    +

    Осваиваемые навыки: "Этот курс познакомит Вас с основными элементами поиска академической информации - мы изучим процесс поиска, начиная с определения стратегии и заканчивая оценкой и документированием результатов поиска. Посещение этого курса сделает Вас опытным искателем информации. Вы научитесь проводить всесторонний поиск литературы на основе Вашего собственного исследовательского задания. Вы пройдете через различные этапы поиска информации, начиная с выбора соответствующих стратегий и методов поиска и заканчивая оценкой результатов поиска, документированием процесса поиска и цитированием своих источников. Посещение курса позволит Вам: - Определить свою информационную потребность - Оценить базы данных и другие информационные ресурсы - Определить стратегию поиска и использовать различные методы поиска - Сформулировать поисковые строки на основе собственного исследовательского задания - Определить релевантные типы материалов - Провести критическую оценку источников - Более эффективно искать в Интернете - Избегать плагиата - Правильно цитировать - Работать со справочным аппаратом - Документировать процесс поиска"

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Информационная безопасность и защита информации

    +

    Учебная нагрузка в кредитах: 9.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of Copenhagen

    +
    + +
    +

    Community Organizing for Social Justice

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 11.566

    +

    Учебная нагрузка (кредиты): 0.386

    +

    Область образования: 6B02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6В02205-История

    +

    Описание курса: В курсе рассматриваются конкретные стратегии организации сообщества для достижения социальной справедливости в многообразном демократическом обществе. В курсе рассматриваются основные концепции социальной справедливости и практические шаги по объединению людей для определения целей и приоритетов, оценки активов и потребностей сообщества, разработки планов действий и создания поддержки для их реализации. Курс включает информацию о том, как формировать основные группы, наращивать организационный потенциал и формулировать стратегию работы с избирателями и союзниками. Поскольку каждый человек учится по-своему, в курсе представлены разнообразные учебные задания, информация об успешных программах, индивидуальные инструкции и упражнения для малых групп, а также практические материалы для решения проблем и планирования программ. Вы можете использовать эти упражнения с отдельными людьми, с небольшой группой людей или с несколькими группами в рамках общественной кампании. Курс основан на работе с людьми, которые стремятся к изменениям в обществе в столичном Детройте - районе, который становится как более сегрегированным, так и более разнообразным. Но он предназначен и для тех, кто хочет создавать изменения в сообществах повсюду. Если Вы пройдете курс с идеями в голове и закончите его с планами в руках, то наша цель будет достигнута.

    +

    Осваиваемые навыки: Advocacy, Community Organizing, Cultural Sensitivity, Diversity Awareness, Cultural Responsiveness, Communication Strategies, Social Sciences, Interpersonal Communications, Social Justice

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Историческая политика

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of Michigan

    +
    + +
    +

    Basic Statistics

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 39.036

    +

    Учебная нагрузка (кредиты): 1.301

    +

    Область образования: 6B02 Искусство и гуманитарные науки

    +

    Область профессиональной подготовки: 6B022 Гуманитарные науки

    +

    Код и название образовательной программы: 6В02205-История

    +

    Описание курса: "Понимание статистики необходимо для понимания исследований в области социальных и поведенческих наук. В этом курсе Вы изучите основы статистики; не только то, как ее вычислять, но и то, как ее оценивать. Этот курс также подготовит Вас к следующему курсу специализации - курсу ""Инференциальная статистика"". В первой части курса мы обсудим методы описательной статистики. Вы узнаете, что такое случаи и переменные и как можно вычислить показатели центральной тенденции (среднее, медиана и мода) и дисперсии (стандартное отклонение и дисперсия). Далее мы обсудим, как оценивать взаимосвязи между переменными, и введем понятия корреляции и регрессии. Вторая часть курса посвящена основам вероятности: вычислению вероятностей, распределению вероятностей и выборочным распределениям. Вам необходимо знать об этих вещах для того, чтобы понять, как работает инференциальная статистика. Третья часть курса состоит из введения в методы инференциальной статистики - методы, которые помогают нам решить, достаточно ли сильны закономерности, которые мы видим в наших данных, чтобы делать выводы об интересующей нас основной совокупности. Мы обсудим доверительные интервалы и тесты на значимость. Вы не только узнаете обо всех этих статистических концепциях, но и будете обучены самостоятельно рассчитывать и генерировать эти статистические данные с помощью свободно распространяемого статистического программного обеспечения."

    +

    Осваиваемые навыки: Statistical Analysis, Probability & Statistics, Statistics, Statistical Methods, Data Analysis, Statistical Inference, Data Science, Applied Mathematics, Probability, Mathematics and Mathematical Modeling, Statistical Modeling, Correlation Analysis, Statistical Hypothesis Testing, Probability Distribution, Data Collection, Mathematical Modeling, Sampling (Statistics), Regression Analysis, Descriptive Statistics, Exploratory Data Analysis, Sample Size Determination, Analytics

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Клиометрика

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: University of Amsterdam

    +
    + +
    +

    Культурная антропология

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: 6B03 Социальные науки, журналистика и информация

    +

    Область профессиональной подготовки: 6В03102-Культурология

    +

    Код и название образовательной программы: 6В03102-Культурология

    +

    Описание курса: Курс изучает элементы, которые объединяют нас – людей – как вид, а также разнообразие и особенности нашего поведения. Этот курс анализирует институты, найденные в разных культурах, таких как религия, экономика, политика, право, семья, их развитие вместе с современным миром, так что теперь в центре анализа находятся и такие феномены, как корпоративные структуры, научные организации, социальные сети и проч. Курс также ставит современную культурную антропологию в исторический контекст, устанавливая ее происхождение и отслеживая ее развитие на протяжении многих лет. Короче говоря, курс направлен на то, чтобы дать слушателю оценку многообразия человеческого опыта и важнейшие инструменты, необходимые для исследования культурных аспектов.

    +

    Осваиваемые навыки: "Владеть базовым категориально-понятийным аппаратом дисциплины, связанным с изучением культурных форм, процессов и практик; Анализировать социокультурные, этнополитические, этносоциальные факторы исторического развития мирового, национальных, региональных, локальных сообществ, антропогенеза, происхождения и эволюцию человека; Характеризовать, классифицировать и систематизировать актуальные вопросы основных культурантропологических школ и направлений; Владеть методами и техниками культурантропологических исследований, сложившихся в рамках различных школ и направлений; Применять навыки культурологического анализа при исследовании различных социокультурных практик в широком социальном и историческом контексте"

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Культурная антропология

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Sociology

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: The course aims to develop skills of evaluating programs for solving social problems based on sociological imagination, critical thinking and sociological research methods to analyze and interpret social processes and institutions. The course is based on the study of theories of sociology associated with the names of key foreign and domestic scholars who have contributed to the development of sociology. Sociology studies a variety of topics from crime to religion, from the family to the state, divisions of race and social class to the shared beliefs of a common culture, from social stability to radical change in whole societies. Basically, everything that surrounds us is a subject of sociology. By discovering theoretical concepts, research methods and analyzing the processes as sociologists, students will be able to find answers these kind of questions: Are we free or do we follow the crowd? Why there is inequality? How people and institutions which make up society interact? What are the things that influence your life (family background, ethnicity, social class, religion, gender)? Why there is war? This is facilitated by the methodology of the course: special situational tasks, interesting games, watching movies, videos, test tasks and etc.

    +

    Осваиваемые навыки: "Тo explain the categories of sociology, trends in the development of society on the basis of sociological macro- and micro-theories and concepts. Тo interpret social reality based on sociological imagination and critical thinking. Тo apply sociological methods and theoretical constructs to develop research design and analyze specific social problems. Тo analyze the features and interrelation of social processes (socio-economic, political, cultural) and social institutions from the position of a sociological perspective and the value system of Kazakhstani society. Тo summarize information on trends in the development of social structures, individual and family, economy, education, culture, religion, social communications and globalization based on comparative research. Тo evaluate programs for solving social problems and situations in own professional fields based on socio-ethical values."

    +

    Язык курса: английский

    +

    Дисциплины для перезачета: Sociology

    +

    Учебная нагрузка в кредитах: 2.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Философия

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: "Целью курса является формирование у студентов способности самостоятельно выявлять, систематизировать и оценивать мировоззренческие компоненты изучения природного и социального мира. Это - подробный и увлекательный экскурс в интеллектуальную историю современной цивилизации. Каждый слушатель сможет познакомиться с классическими и современными проблемами философии (например, философия сознания, проблемы искусственного интеллекта, современной космологии и др.) и выбрать темы, которые интересны именно ему в рамках выбранной образовательной программы. Курс относится к циклу общеобразовательных дисциплин и предназначен для бакалавров любых направлений подготовки. Курс читается кафедрой философии КазНУ им. аль-Фараби – ведущим научным и образовательным центром, аккумулирующим традиции и инновации казахстанской философской школы."

    +

    Осваиваемые навыки: описывать основное содержание онтологии и метафизики в контексте исторического развития философии; классифицировать методы научного и философского познания мира;обосновывать мировоззрение как продукт философского осмысления и изучения природного и социального мира на основе понимания специфики философского мышления; интерпретировать содержание и специфические особенности мифологического, религиозного и научного мировоззрения; аргументировать собственную нравственную позицию по отношению к актуальным проблемам современного глобального общества, социально-культурных и личностных ситуаций для обоснования и принятия этических решений; самостоятельно провести анализ философского и аксиологического содержание проблем в профессиональной области для презентации и обсуждения результатов исследований.

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Философия

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Педагогика высшей школы

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: Если вы хотите влиять на людей и заинтересованы в развитии своих навыков как педагога, методиста, исследователя и ученого. Тогда Вам нужно пройти курс «Педагогика высшей школы». Потребность в великих преподавателях – насущная потребность . Изучив курс, вы сможете влиять непосредственно на качество образования, на доступность обучения. как преподаватель научитесь лучше поддерживать широкий и разнообразный контингент студентов, овладеете успешными практиками организации воспитания и эффективного самоменеджмента.

    +

    Осваиваемые навыки: "Объяснять современную стратегию развития высшего образования в Казахстане на основе осмысления парадигм в мировом образовательном пространстве; Изучать уровень усвоения обучающимися содержания образования, исследовать образовательную среду. Самостоятельно планировать и проводить семинарские, практические, лабораторные занятия с учетом требований разработанных и утвержденных методических указаний. . Строить воспитательный процесс с учетом национальных приоритетов Казахстана и приобщать обучающихся к системе социальных ценностей. Разрабатывать учебно-методические материалы для сопровождения образовательного процесса и реализации инноваций в обучении и воспитании обучающихся."

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Педагогика высшей школы

    +

    Учебная нагрузка в кредитах: 0.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Конфликтология

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: 6B03 Социальные науки, журналистика и информация

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: 6В03106-Политология

    +

    Описание курса: Курс “Конфликтология” предназначен для учащихся выпускных классов средних учебных заведений, желающих поступать на учебу на специальность социология, социальная работа, политология КазНУ им. аль-Фараби. Также он будет полезен выпускникам школ, которые решили поступать на другие факультеты, где изучаются социальные процессы, межличностные коммуникации. Также данный курс полезен для всех желающих изучить природу, причины, типологию , управление и разрешение конфликтных ситуаций . Курс “Конфликтология ” даст комплексные знания по конфликтным ситуациям и способам их разрешения. Конфликты являются неизбежным спутником социальной жизни, определяются природой человека. Современному человеку необходимо научиться снижать уровень конфликтного противостояния, используя классические и современные достижения в области управления и профилактики конфликтов. Важно уметь правильно выявлять их причины, управлять их протеканием и разрешением, снижать уровень конфликтного противостояния. Поэтому представляет научный и практический интерес изучение основ конфликтологии, классических исследований и современных достижений в области развития конфликтологии.

    +

    Осваиваемые навыки: Skills not specified

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Политическая конфликтология

    +

    Учебная нагрузка в кредитах: 0.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Организация научных исследований

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: Просто, доступно, интересно о нормативном знании в научном исследовании. Практические советы, рекомендации опытных людей от науки. Выбор вуза, руководителя, темы исследования. Структура работы и контрольные элементы диссертации. Варианты написания и требования к работе. Научные публикации, научный этикет, научный стиль.

    +

    Осваиваемые навыки: Skills not specified

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Организация научных исследований

    +

    Учебная нагрузка в кредитах: 0.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Мәдениеттану

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: «Мәдениеттану» курсы елімізде қабылданған «Рухани жаңғыру» бағдарламасы бойынша міндетті пәндердің қатарына енеді. Курс студенттерге осы пәнді оқытуда озық әлемдік және отандық оқыту тәжірибесіне негізделеді. Онлайн курста авторлық дәрістермен қатар қосымша материалдар, слайдтар, тапсырмалар, оқыған материалды пысықтайтын тестілер және қорытынды емтихан тестілері келтірілген. Курсты игеру нәтижесінде студенттер қазіргі мәдениеттанудың негізгі ұғымдары, категориялары және қағидаттарымен, әлемдік мәдениеттер және өркениеттердің басты құндылықтарымен, Қазақстан мәдениетіндегі тарихи және заманауи үдерістермен таныса алады.

    +

    Осваиваемые навыки: "Мәдениеттану саласындағы негізгі категориялық-түсініктік аппаратты қолдану. Әр түрлі кезеңдердегі мәдениеттің тарихи типтерінің негізгі сипаттамаларын талдау. Мәдениет дамуының әр түрлі деректеріне әлеуметтік-мәдени сараптама жүргізу. Әлеуметтік-мәдени сипаттағы мәселелерге жеке ұстанымын қалыптастыру және негіздеу. Мәдениет жетістіктерін олардың жасалуының тарихи мәнмәтінін түсіну негізінде бағалау."

    +

    Язык курса: казахский

    +

    Дисциплины для перезачета: Мәдениеттану

    +

    Учебная нагрузка в кредитах: 2.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Культурология

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: В процессе изучения курса «Культурология» вы встретитесь с многочисленными точками зрения, взаимодополняющими друг друга оценками одних и тех же явлений. Ваша самостоятельность и активность должны проявиться в том, чтобы, ознакомившись с разными позициями, определиться в своих предпочтениях, совершенствовать и развивать свои способности. Представленный курс объединяет сильные стороны социальных и гуманитарных наук, опирается на методы и теории истории, философии, социологии, литературоведения и коммуникационных исследований. Творческие задания курса способствуют включению слушателя в практическую реализацию мира культуры.

    +

    Осваиваемые навыки: объяснять базовый категориально-понятийный аппарат на основе рефлексивного усвоения ценностей и достижений культуры; упорядочивать характеристики исторических типов культуры на основе генезиса основных культурных явлений; проводить социокультурный анализ практик культуры для осмысления культуротворческой деятельности современного казахстанского общества; обобщать результаты исследований достижений отечественной культуры, ее современные проблемы и перспективы развития на основе понимания их создания для толерантного восприятия социальных, этнических, конфессиональных и культурных различий; оценивать развитие мировой культуры для понимания богатства культурного наследия.

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Культурология

    +

    Учебная нагрузка в кредитах: 2.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Жоғары мектеп педагогикасы

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: Болашақ жоғары мектеп оқытушылары үшін «Қазіргі жоғары білім беру» мәселелерін жан-жақты қарастырып, университеттің оқу-тәрбие үдерісін қалай ұйымдастыру, пәннің оқу-әдістемелік кешенін құрастыру, дәстүрлі және инновациялық оқыту әдістерін, жаңа білім беру технологияларын тиімді пайдалануды, сонымен бірге куратор-эдвайзерлік қызметті ұйымдастыруды, тәрбиелік іс-шараларды өткізу сценарийлерін жасауды, педагогикалық қарым-қатынас құралдарын, басқару стилдерін нәтижелі қолдануға, білім беру саласындағы реформаларды талдап, даму тенденцияларын саралап, оқып үйренуге мүмкіндік береді.

    +

    Осваиваемые навыки: "заманауи жоғары кәсіби білім берудің даму сатыларын, әдіснамалық аппараттың параметрлері мен әдіснамалық деңгейлерін білу; Қазақстанда жоғары кәсіби білім берудің жүйесіне талдау жасай білу; жоғары мектеп оқытушысының кәсіби-педагогикалық мәдениет мен құзіреттілігі негіздерін игеру; Білім берудің TLA-стратегиясын, ЖОО-да кредиттік жүйемен білім беру бойынша студенттердің өзіндік жұмысын жобалау; заманауи дидактикалық принциптер мен талдау технологиясын білу, білім беру мен тәрбиелеудің технологиясы; Жоғары кәсіби білім берудің мазмұнын құрастыру; Блум таксономиясы бойынша құзіреттілікті бағалау; жоғары мектепте дәстүрлі және инновациялық әдістер мен білім беруді ұйымдастырудың жаңа технологияларын қолдану. ЖОО-да білім беру процесінде студенттер мен оқытушылар арасындағы коммуникативті қарым-қатынас технологиясын бағалау және білім алушыларды ынталандыру. "

    +

    Язык курса: казахский

    +

    Дисциплины для перезачета: Жоғары мектеп педагогикасы

    +

    Учебная нагрузка в кредитах: 0.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Философия

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: «Философия» пәні болашақ мамандардың бойында тәуелсіз сыни ойлау түсінігін қалыптастыру мен маңызды дүниетанымдық ұғымдарды түсінуге бағытталған. Пәнді оқу барысында келесі аспектілер қарастырылады: Философияның пайда болуы мен дамуы. Философияның пәні мен әдістері. Философияның тарихи типтері. Әлемді философиялық түсінудің негізі. Болмыс мәселесі. Онтология және метафизика. Сана мен тіл. Таным мен шығармашылық. Ғылыми және ғылыми емес білім. Ғылым мен техника. Адам философиясы және құндылықтар әлемі. Өмір мен өлім. Өмірдің мәні. Этика. Құндылықтар философиясы. Еркіндік. Эстетика. Қабылдау және әсемдікті жасау. Қоғам мен мәдениет. Тарих философиясы. «Мәңгілік Ел» және «Рухани жаңғыру» – Қазақстанның жаңа философиясы.

    +

    Осваиваемые навыки: Пәнді оқытудың нәтижесінде студенттер мынадай қабілеттерге ие болады: философияның тарихи даму шеңберінде метафизика мен онтологияның негізгі мазмұнын сипаттау; философиялық ойлау тұрғысындағы шындықтың ерекшеліктерін түсіндіру; дүниені танудың философиялық және ғылыми әдістерін жүйелеу; қазіргі дүниедегі адамның әлеуметтік және жеке басының құндылықтары ретінде негізгі дүниетанымдық ұғымдардың рөлі мен маңыздылығын негіздеу; кəсіби саладағы мəселелердің философиялық мазмұнын анықтауға жəне талқылауға арналған нəтижелерді ұсынуға байланысты зерттеулер жүргізу және т.б.

    +

    Язык курса: казахский

    +

    Дисциплины для перезачета: Философия

    +

    Учебная нагрузка в кредитах: 0.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Религиозная философия

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: 6B02203 Религиоведение

    +

    Описание курса: Курс приобщает к достижениям духовной культуры человечества, способствует формированию собственной культуры мышления, развивает и укрепляет ценностные ориентации мировой культуры (терпимости к другим и другому), позволяет понять свое место в мире, найти смысл жизни, обрести и реализовать свое предназначение.

    +

    Осваиваемые навыки: В упрощенной форме изучение терминологического аппарата классической и современной философии и ознакомление с основными философскими школами; ознакомление с основными проблемами научных и религиозных картин мира, человеческого знания и особенностей его проявления в современном обществе, соотношение духовных и материальных ценностей, их роли в жизнедеятельности человека, общества, цивилизации.

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Религиозная антропология

    +

    Учебная нагрузка в кредитах: 6.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Саясаттану

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: Бұл курс қоғамдағы саясаттың табиғатын, оның орны мен рөлін түсініп, саясаттың қыр-сырын білгісі келетін кез келген жастағы білім алушыларға арналған. Бұл пән қоғамның саяси саласы туралы жүйелі білім беріп, азамат ретінде саяси үрдістер мен институттар жұмысына қатысуға және бағалауға, талдау мен болжауға қажетті дағдыларды қалыптастырады.

    +

    Осваиваемые навыки: "негізгі саяси ұғымдарды, қоғам мен оның кіші жүйелерін зерттеуге арналған теориялар мен тәсілдерді меңгеру арқылы саяси саланы зерделеу және бағалау. оқытылатын пәннің аясында ғылыми ой мен теория мазмұны негізінде түрлі қоғамдық салалардағы қарым-қатынастың жағдайын түсіндіру. қазіргі қоғамның және оның саяси институттарының жұмыс істеу ерекшеліктері мен негізгі қағидаттарын меңгеру арқылы оларды талдау, ұсыныстар беру және келешегін болжау. заманауи қоғамның саяси мәселелерін сипаттау мен талдау арқылы саяси процестер мен қатынастардың өзара байланыстарын ашып беру. қазақстандық қоғамның даму үрдістері мен жаңғыру ерекшеліктерін талдау негізінде ондағы саяси институттардың рөліне баға беру."

    +

    Язык курса: казахский

    +

    Дисциплины для перезачета: Саясаттану

    +

    Учебная нагрузка в кредитах: 2.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Психология

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: "«Психология» курсын оқу барысында сіз алатын білімдер, атап айтсақ, тұлғаның психологиялық табиғаты, ұлттық сананың психологиялық негіздері сияқты сұрақтар қоғам мүшесі ретінде аса қажет, осы курста сізге бағдар бере алатын теориялық-практикалық білімдер өмірде сәттілікке жетуге, бәсекеге қабілетті болуға үйретеді. Теориялық зерттеулер, практикалық психологиялық жаттығулар, көптеген психологиялық әдістер «Мен кіммін?» деген негізгі ішкі сұраққа жауап алуға сізді бағыттайды. Өзіңіздің психологиялық портретіңізді жасайсыз, қоршағандармен позитивті қарым-қатынас жасау психотехнологияларын игересіз, қазіргі ақпараттық технологиялардың дамыған заманында өзіңіздің психологиялық денсаулығыңызды нығайта аласыз. Күнделікті өміріңізде өтіп жатқан жағдайларға орай стресстік күйіңізді өзгерте алатын стресс-менджмент, эмоциялық интеллект, әлеуметтік интеллект пен имиджді қалыптастырудың негіздеріне бағдарланған психологиялық семинар-тренигтік бағдарламаларды игересіз, өзіңізге психологиялық көмек беруге машықтанасыз. Ең бастысы сіз өзіңіздің мотивациялық және эмоциялық әлеміңізді тани отырып сіз позитивті жаққа қарай өзгересіз, өзіңізді жеңесіз, сіз сәтті, ұтқыр, динамикалы, интеллектік әлеуеті жоғары, мықты, бәрі сізге қызыға қарайтын тұлғаға айналасыз. Тұлға құрылымындағы Мен-концепциясын игеріп, өзіңізді адекватты бағалау мен құрметтеуге жол тартасыз!"

    +

    Осваиваемые навыки: "Тұлғаның мотивациялық және эмоциялық ерекшеліктерін зерттеу негізінде ұлттық сана туралы теориялық білімдерін қалыптастыру. Құндылықтар жүйесі және өзіндік анықталу негізінде тұлғаның «психологиялық портретін» түсіндіру. Психологиялық денсаулық және әлеуметтену процесін зерттеу үшін психодиагностикалық әдістерді қолдану. Тұлғаралық қарым-қатынас психологиясынталдау арқылы қоғамдық сананы жаңғыртуда психотехнологияларды пайдалану. Тұлғаның мінез-құлық модельдерін талдау арқылы әлеуметтік интеллект және имидж түсініктерін қалыптастыруға бағытталған психологиялық бағдарлама құрастыру."

    +

    Язык курса: казахский

    +

    Дисциплины для перезачета: Психология

    +

    Учебная нагрузка в кредитах: 2.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Психология

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: Курс направлен на развитие способностей анализа жизненных и профессиональных ситуаций с помощью социально-психологических знаний. Курс предназначен для повышения общей психологической компетентности и культуры будущего специалиста, осознания своего прошлого, настоящего и будущего с психологических позиций, а также для освоения знаний социально-психологических закономерностей поведения личности.

    +

    Осваиваемые навыки: "Понимать роль психологической науки в системе наук; объяснять роль психологического знания для саморазвития личности и применения в профессиональной деятельности. Понимать сущность и причины психологических явлений и событий, особенности развития и формирования человека как индивида, личности и индивидуальности. Проводить психологическую оценку собственной личности, ситуации, группы людей. Применять психологические знания в решении сложных коммуникативных задач, выхода из конфликтных ситуаций и развитии самоэффективности через решение разных коммуникативных кейсов. Применять навыки саморегуляции личности для профилактики и коррекции негативных психологических состояний."

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Психология

    +

    Учебная нагрузка в кредитах: 2.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Әлеуметтану

    +

    Тип курса: MOOC

    +

    Уровень сложности: средний

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 0.0

    +

    Учебная нагрузка (кредиты): 0.0

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: Not Specified

    +

    Описание курса: Әлеуметтану пәні қоғамның әлеуметтік жағдайын теориялық тұрғыдан түсінуге, сипаттауға және жүйелеуге, тәжірибелік тұрғыдан деректер жинауға мүмкіндік береді. Қоғам күрделі әлеуметтік байланыстар мен қарым-қатынастар жүйесінен тұрады. Мұндай байланыстар өзара серіктестікке, ынтымақтастыққа негізделіп, жасампаздыққа немесе қарама-қайшылықтар мен дау-жанжалдық ситуациялардың әсерімен қиындықтарға да алып келуі мүмкін. Әлеуметтанудың мақсаты - түрлі әлеуметтік байланыстардың қалыптасу заңдылықтарын түсіндіріп, дауларды шешудің тиімді жолдарын көрсету. Әлеуметтік байланыстар мен қарым-қатынастардың қалыптасу сипаты адамдардың құндылықтарымен, өмірге деген көзқарастарымен, мәдениетімен, психологиясымен анықталады және олардың қалай қалыптасатындығы әлеуметтік жағдайға белгілі бір деңгейде байланысты. Сондықтан да әлеуметтануды оқу арқылы өзімізді, өзіміз өмір сүріп отырған ортаны, түрлі топтарды, олардың әлеуметтік психологиясын, мәдениетін, түрлі ситуацияда қандай әрекетке дайын болатындығын түсіне аламыз.

    +

    Осваиваемые навыки: "Әлеуметтану ғылымының зерттеу ерекшеліктерін айқындау негізінде зерттеулерді ұйымдастыруды және алғашқы мәліметтерді жинау әдістерін еркін түрде қолдана білуді қалыптастыру; Қоғамның әлеуметтік құрылымына қатысты нәтижелерді жинақтау арқылы тұлға мен отбасының алатын орны мен әлеуметтену ерекшеліктерін негіздеу; Девиацияның қалыптасу жағдайларына талдау жасау нәтижесінде оларды реттестірудегі, қоғамдық сананы дамытудағы әлеуметтік институттардың маңызын көрсету; Жаһандану жағдайындағы теңсіздіктерді айқындау және ақпараттық қоғамның олардың қалыптасуына әсеріне әлеуметтанулық тұрғыдан қорытынды жасау; Қоғамдағы модернизациялық өзгерістерге, урбанизация процесіне талдау жасау негізінде олардың халықтың әлеуметтік денсаулығының сапалық көрсеткіштеріне әсерін бағалау."

    +

    Язык курса: казахский

    +

    Дисциплины для перезачета: Әлеуметтану

    +

    Учебная нагрузка в кредитах: 2.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Основные процессы и аппараты химической технологии

    +

    Тип курса: MOOC

    +

    Уровень сложности: продвинутый

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 72.288

    +

    Учебная нагрузка (кредиты): 2.41

    +

    Область образования: 6B07 Инженерные, обрабатывающие и строительные отрасли

    +

    Область профессиональной подготовки: 6B071 Инженерия и инженерное дело

    +

    Код и название образовательной программы: B060 Химическая инженерия и процессы

    +

    Описание курса: Курс «Основные процессы и аппараты химической технологии» посвящен общим подходам и приемам по расчету основных параметров процессов химической технологии. Данный курс направлен на то, чтобы студенты и слушатели химико-технологических специальностей могли самостоятельно освоить, закрепить и в дальнейшем проводить анализ, математическое описание и инженерные расчеты для конкретных технологических процессов в своей профессиональной деятельности. Простой и быстрый доступ к учебным материалам по инженерной дисциплине, охватывающей темы по гидродинамическим, тепловым и массообменным процессам, а также свободный график обучения делает данный курс уникальным и удобным для студентов и инженеров химико-технологического профиля без отрыва от производства.

    +

    Осваиваемые навыки: "1. Умение объяснять технологические процессы и устройства химической технологии, используя основные законы сохранения массы, энергии, термодинамического равновесия, кинетики, теории подобия, тепло- и массопереноса. 2. Умение применять расчётные уравнения основных процессов и устройств химической технологии. 3. Умение составлять принципиальные схемы производств химической технологии на основе теплового и материального балансов."

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: 1. Тепловые и массобменные процессы 2.Thermal and mass transfer processes

    +

    Учебная нагрузка в кредитах: 9.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. аль-Фараби

    +
    + +
    +

    Физическая химия

    +

    Тип курса: MOOC

    +

    Уровень сложности: продвинутый

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 48.192

    +

    Учебная нагрузка (кредиты): 1.606

    +

    Область образования: 6B05 - Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 6B053 - Физические и химические науки

    +

    Код и название образовательной программы: 6В05301, Химия

    +

    Описание курса: Физикалық химия курында қарастыралтын сұрақтар қазіргі кездегі химия және химиялық тенологияның теориялық фундаменті болады және алған теоориялық білім термодинамика заңдарына сүйене отырып, химиялық процестердің энергетикасын, өздігінен өту бағытын анықтау, гомогенді және гетерогенді жүйелердегі химиялық тепе-теңдік күйге әр түрлі факторлар әсерін және ерітінділер заңдылықтарын, кинетика және электрохимия негіздерін білу қажет.

    +

    Осваиваемые навыки: Ұсынылып отрыған бағдарлама жоғары сынып оқушылары мен химия мамандықтары студенттерінің базалық білімін нығайтып, химиялық процестердің энергетикалық сипаттамаларын есептеудің теориясы мен практикасы бойынша жалпы және пәндік құзыреттілікті қалыптастыру.

    +

    Язык курса: казахский

    +

    Дисциплины для перезачета: Физикалық химия

    +

    Учебная нагрузка в кредитах: 9.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им.аль-Фараби

    +
    + +
    +

    Органическая химия алифатических соединений

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 18.072

    +

    Учебная нагрузка (кредиты): 0.602

    +

    Область образования: 6B07 Инженерные, обрабатывающие и строительные отрасли

    +

    Область профессиональной подготовки: 6B072 Производственные и обрабатывающие отрасли

    +

    Код и название образовательной программы: 6B07104 Химическая технология органических веществ

    +

    Описание курса: Сформировать способность применять основы органической химии для описания и оценивания свойств, методы синтеза алифатических и циклических органических соединений, выявлять достоинства, недостатки, предлагать пути модификации. Курс формирует теоретико-методологическую основу понимания основных закономерностей: строение и реакционная способность алифатических и циклических органических соединений. Будут рассмотрены: классификация, строения, изомерия, способы получения, свойства и применения.

    +

    Осваиваемые навыки: Сформировать способность применять основы органической химии для описания и оценивания свойств, методы синтеза алифатических и циклических органических соединений, выявлять достоинства, недостатки, предлагать пути модификации. Курс формирует теоретико-методологическую основу понимания основных закономерностей: строение и реакционная способность алифатических и циклических органических соединений. Будут рассмотрены: классификация, строения, изомерия, способы получения, свойства и применения.

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: "Органическая химия алифатических и циклических соединений "

    +

    Учебная нагрузка в кредитах: 9.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им.аль-Фараби

    +
    + +
    +

    Органическая химия алифатических соединений

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 18.072

    +

    Учебная нагрузка (кредиты): 0.602

    +

    Область образования: 6B07 Инженерные, обрабатывающие и строительные отрасли

    +

    Область профессиональной подготовки: 6B072 Производственные и обрабатывающие отрасли

    +

    Код и название образовательной программы: 6B07102 Химическая инженерия

    +

    Описание курса: Сформировать способность устанавливать связь между строением органических веществ и их реакционной способностью, классифицировать методы синтеза основных классов органических соединений и качественные химические реакции на функциональные группы, методы выделения и идентификации органических соединений, использовать навыки работы с веществами, лабораторными установками в практической деятельности.

    +

    Осваиваемые навыки: Сформировать способность устанавливать связь между строением органических веществ и их реакционной способностью, классифицировать методы синтеза основных классов органических соединений и качественные химические реакции на функциональные группы, методы выделения и идентификации органических соединений, использовать навыки работы с веществами, лабораторными установками в практической деятельности.

    +

    Язык курса: русский

    +

    Дисциплины для перезачета: Химия углеводородов и их функциональных производных

    +

    Учебная нагрузка в кредитах: 9.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им.аль-Фараби

    +
    + +
    +

    Биохимия

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Coursera

    +

    Учебная нагрузка (часы): 12.048

    +

    Учебная нагрузка (кредиты): 0.402

    +

    Область образования: 6B05 - Естественные науки, математика и статистика

    +

    Область профессиональной подготовки: 6B053 - Физические и химические науки

    +

    Код и название образовательной программы: 6В05301, Химия

    +

    Описание курса: Курс Энергия алмасудың негізгі сатыларын ажырату, оның биохимиялық принциптерін түсінуге мүмкіндік береді

    +

    Осваиваемые навыки: Студенттер Энергия алмасуының сатыларын ажырататын болады.

    +

    Язык курса: казахский

    +

    Дисциплины для перезачета: Биохимия

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: Korea Advanced Institute of Science and Technology

    +
    + +
    +

    Медика-биологиялық мақсаттағы полимерлер химиясы

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 15.662

    +

    Учебная нагрузка (кредиты): 0.522

    +

    Область образования: 6B07 Инженерные, обрабатывающие и строительные отрасли

    +

    Область профессиональной подготовки: 6B072 Производственные и обрабатывающие отрасли

    +

    Код и название образовательной программы: 6В07201-Фармацептикалық технология өндірісі

    +

    Описание курса: Курс медика-биологиялық мақсаттағы полимерлердің сипаттамаларын, фармацевтика мен медицинада қолдану мақсаттарына сәйкес оларға қойылатын негізгі талаптарды, оларды синтездеу және модификациялау әдістерін, сонымен қатар негізгі физикалық және химиялық қасиеттері мен олардың практикалық қолдану аспектілері жан-жақты оқытуға арналған.

    +

    Осваиваемые навыки: "ОН 1 Биомедициналық полимерлердің түсініктері мен терминдерін, жіктелуі мен номенклатурасын; медика-биологиялық мақсаттағы полимерлі материалдарға қойылатын талаптарды сипаттауға ОН 2 Медициналық дәрежеде таза және бағытталған биологиялық әсер ететін полимерлерді синтездеу әдістерін сипаттауға ОН 3 Биомедициналық мақсаттағы полимерлер мен полимерлік материалдардың синтезін жүзеге асыра алу дағдыларын көрсету ОН 4 Еріткіштің термодинамикалық сапасын, макромолекулалардың молекулалық-массалық және басқа да сипаттамаларын бағалау үшін полимерлердің ерітінділерін зерттеу әдістерін қолдану "

    +

    Язык курса: казахский

    +

    Дисциплины для перезачета: Медика-биологиялық мақсаттағы полимерлер химиясы

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. Аль-Фараби

    +
    + +
    +

    Аналитикалық химия

    +

    Тип курса: MOOC

    +

    Уровень сложности: базовый

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 15.662

    +

    Учебная нагрузка (кредиты): 0.522

    +

    Область образования: Not Specified

    +

    Область профессиональной подготовки: Not Specified

    +

    Код и название образовательной программы: 6B05 - Естественные науки, математика и статистика

    +

    Описание курса: Базалық пән бойынша терең теориялық білім қалыптастыру.Химиялық талдау бойынша толық мәлімет беру арқылы базалық білімдерін қалыптастыру.

    +

    Осваиваемые навыки: "Негізгі пән ретінде толықтырылған әрі терең білім беру. ЖАОК-ның қолдауы: ЖАОК дәстүрлі оқытуда СӨЖ ретінде, «Төңкерілген сынып» технологиясын қолдана отырып немесе қолданбай-ақ пән бойынша қосымша материал ретінде қолданылады. "

    +

    Язык курса: казахский

    +

    Дисциплины для перезачета: Химиялық талдау 1

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. Аль-Фараби

    +
    + +
    +

    Аналитикалық химия. Сапалық талдау.

    +

    Тип курса: SPOC

    +

    Уровень сложности: базовый

    +

    Платформа: Openkaznu

    +

    Учебная нагрузка (часы): 12.048

    +

    Учебная нагрузка (кредиты): 0.402

    +

    Область образования: 6B07 Инженерные, обрабатывающие и строительные отрасли

    +

    Область профессиональной подготовки: 6B072 Производственные и обрабатывающие отрасли

    +

    Код и название образовательной программы: "В068 Производство продуктов питания "

    +

    Описание курса: Базалық пән бойынша терең теориялық және практикалық білім қалыптастыру. Сапалық талдау бойынша толық мәлімет беру арқылы базалық білімдерін қалыптастыру.

    +

    Осваиваемые навыки: "Негізгі пән ретінде толықтырылған әрі терең білім беру. SPOC-ның қолдауы: SPOC дәстүрлі оқытуда СӨЖ ретінде пән бойынша қосымша материал ретінде қолданылады. "

    +

    Язык курса: казахский

    +

    Дисциплины для перезачета: Азық-түліктің талдауы

    +

    Учебная нагрузка в кредитах: 5.0

    +

    Ссылка на курс: Открыть курс

    +

    Организация: КазНУ им. Аль-Фараби + + +чтобы все фильтры работали как у других

    +
    +
    + +
    + + + + + diff --git a/lms/templates/static_templates/competition.html b/lms/templates/static_templates/competition.html new file mode 100644 index 000000000000..892e262fd3a3 --- /dev/null +++ b/lms/templates/static_templates/competition.html @@ -0,0 +1,644 @@ +<%page expression_filter="h"/> +<%! from django.utils.translation import gettext as _ %> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Competition")} + + + + + + Конкурс педагогических сценариев онлайн-курсов + + + + + + +
    +
    +

    ${_("Competition for the Selection of Pedagogical Scenarios for Online Courses")}

    +

    ${_("Al-Farabi Kazakh National University")}

    +
    +
    + +
    + +
    +
    +

    ${_("About the Competition")}

    +
    + +
    +

    ${_("The Office of Academic and Digital Innovations is accepting applications for participation in the competition of pedagogical scenarios for the creation and publication of online courses (MOOC/SPOC)")}.

    +
    +

    ${_("Attention!")}

    +

    ${_("The competition for pedagogical scenarios for the development and publication of online courses (MOOC/SPOC), organized by the Office of Academic and Digital Innovations, has been canceled in 2026.")}

    +

    ${_("Order on cancellation.")}

    +

    ${_("Information about the next competition will be published on this page.")}

    + +
    +
    +

    ${_("MOOC Competition Regulations")}

    + +
    + +

    ${_("Both creative teams of university departments and individual teaching or research staff of the university can participate in the competition")}.

    +

    ${_("Based on the results of the competition, the commission will approve and release 5 MOOC and 5 SPOC")}.

    +
    +
    + + +
    +
    +

    ${_("Competition Stages")}

    +
    + +
    +
    +
    + ${_("Stage")} 1 +

    ${_("Registration and Submission of Applications")}

    +

    ${_("Until")} ${_("February")} 10, 2025

    +
    +
    + +
    +
    + ${_("Stage")} 2 +

    ${_("Public Defense of Pedagogical Scenarios")}

    +

    ${_("Until")} ${_("February")} 17, 2025

    +
    +
    + +
    +
    + ${_("Stage")} 3 +

    ${_("Evaluation of Applications and Selection of Winners")}

    +

    ${_("Until")} ${_("February")} 24, 2025

    +
    +
    + +
    +
    + ${_("Stage")} 4 +

    ${_("Approval of Competition Results")}

    +

    ${_("Until")} ${_("March")} 3, 2025

    +
    +
    +
    +
    + + +
    +
    +

    ${_("Application Requirements")}

    +
    + +
    +
    +
    +
    + +
    +

    ${_("Cover Letter")}

    + +
    + +
    +
    + +
    +

    ${_("Pedagogical Scenario of an Online Course")}

    + +
    + +
    +
    + +
    +

    ${_("Lecture Video Recording")}

    + +
    +
    + +
    +
    +
    + +
    +

    ${_("Online Course Certificate")}

    + +
    + +
    +
    + +
    +

    ${_("Author Information")}

    + +
    +
    +
    +
    + + +
    +
    +

    ${_("Competition Announcement")}

    +
      +
    • + ${_("The Office of Academic and Digital Innovations")} ${_("is pleased to announce the launch of the competition for the selection of pedagogical scenarios for online courses.")} +
    • +
    • + ${_("Stage")} 1: ${_("Application submission")} — ${_("February")} 10–17, 2025 +
    • +
    • + ${_("Stage")} 2: ${_("Selection of applications for participation")} — ${_("February")} 18–24, 2025 +
    • +
    • + ${_("Stage")} 3: ${_("Public defense of projects")} — ${_("February")} 25–28, 2025 +
    • +
    • + ${_("Stage")} 4: ${_("Announcement of winners")} — ${_("March")} 3–10, 2025 +
    • +
    • + ${_("Applications are accepted online via the form on the Open Kaznu website. Details and application templates can be found on the competition page")}. +
    • +
    +
    +
    + + + + + +
    +

    ${_("Contacts")}

    +

    ${_("For all inquiries, please contact the Office of Academic and Digital Innovations: Building - Rectorate, Office 207, phone")} +7 727 377 33 30 ${_("(ext.")} 31-24, 11-33, 16-47)

    +
    +
    + + + diff --git a/lms/templates/static_templates/detect.html b/lms/templates/static_templates/detect.html new file mode 100644 index 000000000000..2bbe171a40d2 --- /dev/null +++ b/lms/templates/static_templates/detect.html @@ -0,0 +1,244 @@ +<%page expression_filter="h"/> +<%! from django.utils.translation import gettext as _ %> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Detect")} + + + + + + + + + + + +
    +
    +

    ${_("Chatbot Usage Verification System")}

    +

    ${_("Al-Farabi Kazakh National University")}

    +
    +
    + +
    + +
    +
    +

    ${_("Content Verification")}

    +
    + +
    +

    ${_("Content Analysis Tool")}

    +

    ${_("Use the built-in verification system to analyze text content and determine the likelihood of it being created with chatbots or artificial intelligence.")}

    + +
    + +
    +
    ${_("Open in full size")}
    +
    +
    + +

    ${_("This tool allows you to check texts for AI-generated content, which is especially important for ensuring academic integrity in the educational process.")}

    +
    +
    + + +
    +

    ${_("Contacts")}

    +

    ${_("For all inquiries, please contact the Office of Academic and Digital Innovations: Building - Rectorate, Office 207, phone")} +7 727 377 33 30 ${_("(ext.")} 31-24, 11-33, 16-47)

    +
    +
    + + + + + diff --git a/lms/templates/static_templates/honor_code.html b/lms/templates/static_templates/honor_code.html new file mode 100644 index 000000000000..22f7a80df7c4 --- /dev/null +++ b/lms/templates/static_templates/honor_code.html @@ -0,0 +1,16 @@ +<%page expression_filter="h"/> +<%! from django.utils.translation import gettext as _ %> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Donate")} + +
    +
    +

    + <%block name="pageheader">${page_header or _("Donate")} +

    +

    + <%block name="pagecontent">${page_content or _("This page left intentionally blank. Feel free to add your own content.")} +

    +
    +
    diff --git a/lms/templates/word_cloud.html b/lms/templates/word_cloud.html index 9e2623f19379..a183944ecf56 100644 --- a/lms/templates/word_cloud.html +++ b/lms/templates/word_cloud.html @@ -7,7 +7,7 @@ data-ajax-url="${ajax_url}" > % if display_name: -

    ${display_name}

    +

    ${display_name}

    % endif % if instructions is not None: diff --git a/lms/urls.py b/lms/urls.py index 1ae425c9a9be..4ee979a84351 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -50,7 +50,8 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_authn.views.login import redirect_to_lms_login from openedx.features.enterprise_support.api import enterprise_enabled - +from lms.djangoapps.univerapi import views as univer_views +from lms.djangoapps.news import views as news_views RESET_COURSE_DEADLINES_NAME = 'reset_course_deadlines' RENDER_XBLOCK_NAME = 'render_xblock' RENDER_VIDEO_XBLOCK_NAME = 'render_public_video_xblock' @@ -94,6 +95,13 @@ urlpatterns = [ + path('news/', news_views.news_list, name='news_list'), + path('news//', news_views.news_detail, name='news_detail'), + path('news/create/', news_views.news_create, name='news_create'), + path('analyze/', news_views.analyze, name='analyze'), + path('go-to-exam/', news_views.go_to_exam, name='exam'), + path('finish-exam/', news_views.finish_exam, name='finish_exam'), + path('api/univertest/', univer_views.UniverTestView.as_view(), name='univer_test'), path('', branding_views.index, name='root'), # Main marketing page, or redirect to courseware path('', include('common.djangoapps.student.urls')), diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0030_courseoverview_complexity_and_more.py b/openedx/core/djangoapps/content/course_overviews/migrations/0030_courseoverview_complexity_and_more.py new file mode 100644 index 000000000000..75f46e3ce6b5 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0030_courseoverview_complexity_and_more.py @@ -0,0 +1,40 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "course_overviews", + "0029_alter_historicalcourseoverview_options", + ), + ] + + operations = [ + migrations.AddField( + model_name="courseoverview", + name="complexity", + field=models.CharField( + max_length=10, + default="medium", + choices=[ + ("easy", "Easy"), + ("medium", "Medium"), + ("hard", "Hard"), + ], + ), + ), + migrations.AddField( + model_name="historicalcourseoverview", + name="complexity", + field=models.CharField( + max_length=10, + default="medium", + choices=[ + ("easy", "Easy"), + ("medium", "Medium"), + ("hard", "Hard"), + ], + ), + ), + ] diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0031_courseoverview_faculty_directions.py b/openedx/core/djangoapps/content/course_overviews/migrations/0031_courseoverview_faculty_directions.py new file mode 100644 index 000000000000..702f8574a024 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0031_courseoverview_faculty_directions.py @@ -0,0 +1,34 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "course_overviews", + "0030_courseoverview_complexity_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="courseoverview", + name="faculty", + field=models.TextField(null=True), + ), + migrations.AddField( + model_name="courseoverview", + name="directions", + field=models.TextField(null=True), + ), + migrations.AddField( + model_name="historicalcourseoverview", + name="faculty", + field=models.TextField(null=True), + ), + migrations.AddField( + model_name="historicalcourseoverview", + name="directions", + field=models.TextField(null=True), + ), + ] diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py index 10a56f0868fb..07f471e9d5bf 100644 --- a/openedx/core/djangoapps/content/course_overviews/models.py +++ b/openedx/core/djangoapps/content/course_overviews/models.py @@ -59,6 +59,11 @@ class CourseOverview(TimeStampedModel): .. no_pii: """ + COMPLEXITY_CHOICES = [ + ("easy", "Easy"), + ("medium", "Medium"), + ("hard", "Hard"), + ] class Meta: app_label = 'course_overviews' @@ -126,6 +131,17 @@ class Meta: short_description = models.TextField(null=True) course_video_url = models.TextField(null=True) effort = models.TextField(null=True) + # New column + complexity = models.CharField( + max_length=10, + choices=COMPLEXITY_CHOICES, + default="medium", + null=False, + blank=False, + ) + faculty = models.TextField(null=True) + directions = models.TextField(null=True) + self_paced = models.BooleanField(default=False) marketing_url = models.TextField(null=True) eligible_for_financial_aid = models.BooleanField(default=True) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index b3614e9cc673..b866f13dc474 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -653,29 +653,6 @@ def _delete_index_doc(doc_id) -> None: _wait_for_meili_tasks(tasks) -def delete_all_draft_docs_for_library(library_key: LibraryLocatorV2) -> None: - """ - Deletes draft documents for the given XBlocks from the search index - """ - current_rebuild_index_name = _get_running_rebuild_index_name() - client = _get_meilisearch_client() - # Delete all documents where last_published is null i.e. never published before. - delete_filter = [ - f'{Fields.context_key}="{library_key}"', - # This field should only be NULL or have a value, but we're also checking IS EMPTY just in case. - # Inner arrays are connected by an OR - [f'{Fields.last_published} IS EMPTY', f'{Fields.last_published} IS NULL'], - ] - - tasks = [] - if current_rebuild_index_name: - # If there is a rebuild in progress, the documents will also be deleted from the new index. - tasks.append(client.index(current_rebuild_index_name).delete_documents(filter=delete_filter)) - tasks.append(client.index(STUDIO_INDEX_NAME).delete_documents(filter=delete_filter)) - - _wait_for_meili_tasks(tasks) - - def upsert_library_block_index_doc(usage_key: UsageKey) -> None: """ Creates or updates the document for the given Library Block in the search index diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index 6a40f049bf96..28b5e74450a6 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -71,6 +71,7 @@ class Fields: # The "content" field is a dictionary of arbitrary data, depending on the block_type. # It comes from each XBlock's index_dictionary() method (if present) plus some processing. # Text (html) blocks have an "html_content" key in here, capa has "capa_content" and "problem_types", and so on. + # Containers store their list of child usage keys here. content = "content" # Collections use this field to communicate how many entities/components they contain. @@ -87,6 +88,7 @@ class Fields: published = "published" published_display_name = "display_name" published_description = "description" + published_content = "content" published_num_children = "num_children" # Note: new fields or values can be added at any time, but if they need to be indexed for filtering or keyword @@ -212,6 +214,8 @@ class implementation returns only: Fields.access_id: _meili_access_id_from_context_key(block.usage_key.context_key), Fields.breadcrumbs: [], } + if hasattr(block, "edited_on"): + block_data[Fields.modified] = block.edited_on.timestamp() # Get the breadcrumbs (course, section, subsection, etc.): if block.usage_key.context_key.is_course: # Getting parent is not yet implemented in Learning Core (for libraries). cur_block = block @@ -345,13 +349,10 @@ def _collections_for_content_object(object_id: OpaqueKey) -> dict: collections = authoring_api.get_entity_collections( component.learning_package_id, component.key, - ) + ).values('key', 'title') elif isinstance(object_id, LibraryContainerLocator): - container = lib_api.get_container_from_key(object_id) - collections = authoring_api.get_entity_collections( - container.publishable_entity.learning_package_id, - container.key, - ) + container = lib_api.get_container(object_id, include_collections=True) + collections = container.collections else: log.warning(f"Unexpected key type for {object_id}") @@ -362,8 +363,8 @@ def _collections_for_content_object(object_id: OpaqueKey) -> dict: return result for collection in collections: - result[Fields.collections][Fields.collections_display_name].append(collection.title) - result[Fields.collections][Fields.collections_key].append(collection.key) + result[Fields.collections][Fields.collections_display_name].append(collection["title"]) + result[Fields.collections][Fields.collections_key].append(collection["key"]) return result @@ -579,9 +580,13 @@ def searchable_doc_for_container( container = lib_api.get_container(container_key) except lib_api.ContentLibraryContainerNotFound: # Container not found, so we can only return the base doc + log.error(f"Container {container_key} not found") return doc - draft_num_children = lib_api.get_container_children_count(container_key, published=False) + draft_children = lib_api.get_container_children( + container_key, + published=False, + ) publish_status = PublishStatus.published if container.last_published is None: publish_status = PublishStatus.never @@ -592,7 +597,13 @@ def searchable_doc_for_container( Fields.display_name: container.display_name, Fields.created: container.created.timestamp(), Fields.modified: container.modified.timestamp(), - Fields.num_children: draft_num_children, + Fields.num_children: len(draft_children), + Fields.content: { + "child_usage_keys": [ + str(child.usage_key) + for child in draft_children + ], + }, Fields.publish_status: publish_status, Fields.last_published: container.last_published.timestamp() if container.last_published else None, }) @@ -601,10 +612,19 @@ def searchable_doc_for_container( doc[Fields.breadcrumbs] = [{"display_name": library.title}] if container.published_version_num is not None: - published_num_children = lib_api.get_container_children_count(container_key, published=True) + published_children = lib_api.get_container_children( + container_key, + published=True, + ) doc[Fields.published] = { - Fields.published_num_children: published_num_children, Fields.published_display_name: container.published_display_name, + Fields.published_num_children: len(published_children), + Fields.published_content: { + "child_usage_keys": [ + str(child.usage_key) + for child in published_children + ], + }, } return doc diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py index 998b2ef870ab..315d3cde53fd 100644 --- a/openedx/core/djangoapps/content/search/handlers.py +++ b/openedx/core/djangoapps/content/search/handlers.py @@ -23,12 +23,14 @@ LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, LIBRARY_COLLECTION_CREATED, LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, LIBRARY_CONTAINER_CREATED, LIBRARY_CONTAINER_DELETED, LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, XBLOCK_CREATED, XBLOCK_DELETED, XBLOCK_UPDATED, @@ -37,6 +39,7 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.search.models import SearchAccess +from openedx.core.djangoapps.content_libraries import api as lib_api from .api import ( only_if_meilisearch_enabled, @@ -136,6 +139,32 @@ def library_block_updated_handler(**kwargs) -> None: upsert_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) +@receiver(LIBRARY_BLOCK_PUBLISHED) +@only_if_meilisearch_enabled +def library_block_published_handler(**kwargs) -> None: + """ + Update the index for the content library block when its published version + has changed. + """ + library_block_data = kwargs.get("library_block", None) + if not library_block_data or not isinstance(library_block_data, LibraryBlockData): # pragma: no cover + log.error("Received null or incorrect data for event") + return + + # The PUBLISHED event is sent for any change to the published version including deletes, so check if it exists: + try: + lib_api.get_library_block(library_block_data.usage_key) + except lib_api.ContentLibraryBlockNotFound: + log.info(f"Observed published deletion of library block {str(library_block_data.usage_key)}.") + # The document should already have been deleted from the search index + # via the DELETED handler, so there's nothing to do now. + return + + # Update content library index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches results. This is only a single document update so is very fast. + upsert_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) + + @receiver(LIBRARY_BLOCK_DELETED) @only_if_meilisearch_enabled def library_block_deleted(**kwargs) -> None: @@ -162,14 +191,14 @@ def content_library_updated_handler(**kwargs) -> None: if not content_library_data or not isinstance(content_library_data, ContentLibraryData): # pragma: no cover log.error("Received null or incorrect data for event") return + library_key = content_library_data.library_key - # Update content library index synchronously to make sure that search index is updated before - # the frontend invalidates/refetches index. - # Currently, this is only required to make sure that removed/discarded components are removed - # from the search index and displayed to user properly. If it becomes a performance bottleneck - # for other update operations other than discard, we can update CONTENT_LIBRARY_UPDATED event - # to include a parameter which can help us decide if the task needs to run sync or async. - update_content_library_index_docs.apply(args=[str(content_library_data.library_key)]) + # For now we assume the library has been renamed. Few other things will trigger this event. + + # Update ALL items in the library, because their breadcrumbs will be outdated. + # TODO: just patch the "breadcrumbs" field? It's the same on every one. + # TODO: check if the library display_name has actually changed before updating all items? + update_content_library_index_docs.apply(args=[str(library_key)]) @receiver(LIBRARY_COLLECTION_CREATED) @@ -248,17 +277,34 @@ def library_container_updated_handler(**kwargs) -> None: log.error("Received null or incorrect data for event") return - if library_container.background: - update_library_container_index_doc.delay( - str(library_container.container_key), - ) - else: - # Update container index synchronously to make sure that search index is updated before - # the frontend invalidates/refetches index. - # See content_library_updated_handler for more details. - update_library_container_index_doc.apply(args=[ - str(library_container.container_key), - ]) + update_library_container_index_doc.apply(args=[ + str(library_container.container_key), + ]) + + +@receiver(LIBRARY_CONTAINER_PUBLISHED) +@only_if_meilisearch_enabled +def library_container_published_handler(**kwargs) -> None: + """ + Update the index for the content library container when its published + version has changed. + """ + library_container = kwargs.get("library_container", None) + if not library_container or not isinstance(library_container, LibraryContainerData): # pragma: no cover + log.error("Received null or incorrect data for event") + return + # The PUBLISHED event is sent for any change to the published version including deletes, so check if it exists: + try: + lib_api.get_container(library_container.container_key) + except lib_api.ContentLibraryContainerNotFound: + log.info(f"Observed published deletion of container {str(library_container.container_key)}.") + # The document should already have been deleted from the search index + # via the DELETED handler, so there's nothing to do now. + return + + update_library_container_index_doc.apply(args=[ + str(library_container.container_key), + ]) @receiver(LIBRARY_CONTAINER_DELETED) @@ -275,3 +321,6 @@ def library_container_deleted(**kwargs) -> None: # Update content library index synchronously to make sure that search index is updated before # the frontend invalidates/refetches results. This is only a single document update so is very fast. delete_library_container_index_doc.apply(args=[str(library_container.container_key)]) + # TODO: post-Teak, move all the celery tasks directly inline into this handlers? Because now the + # events are emitted in an [async] worker, so it doesn't matter if the handlers are synchronous. + # See https://github.com/openedx/edx-platform/pull/36640 discussion. diff --git a/openedx/core/djangoapps/content/search/tasks.py b/openedx/core/djangoapps/content/search/tasks.py index 1ab77aba383f..5015f6912b10 100644 --- a/openedx/core/djangoapps/content/search/tasks.py +++ b/openedx/core/djangoapps/content/search/tasks.py @@ -86,9 +86,6 @@ def update_content_library_index_docs(library_key_str: str) -> None: log.info("Updating content index documents for library with id: %s", library_key) api.upsert_content_library_index_docs(library_key) - # Delete all documents in this library that were not published by above function - # as this task is also triggered on discard event. - api.delete_all_draft_docs_for_library(library_key) @shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index ee70bd444721..90b6a407e717 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -61,19 +61,27 @@ def setUp(self): # Clear the Meilisearch client to avoid side effects from other tests api.clear_meilisearch_client() + modified_date = datetime(2024, 5, 6, 7, 8, 9, tzinfo=timezone.utc) # Create course - self.course = self.store.create_course( - "org1", - "test_course", - "test_run", - self.user_id, - fields={"display_name": "Test Course"}, - ) - course_access, _ = SearchAccess.objects.get_or_create(context_key=self.course.id) - self.course_block_key = "block-v1:org1+test_course+test_run+type@course+block@course" - - # Create XBlocks - self.sequential = self.store.create_child(self.user_id, self.course.location, "sequential", "test_sequential") + with freeze_time(modified_date): + self.course = self.store.create_course( + "org1", + "test_course", + "test_run", + self.user_id, + fields={"display_name": "Test Course"}, + ) + course_access, _ = SearchAccess.objects.get_or_create(context_key=self.course.id) + self.course_block_key = "block-v1:org1+test_course+test_run+type@course+block@course" + + # Create XBlocks + self.sequential = self.store.create_child( + self.user_id, + self.course.location, + "sequential", + "test_sequential" + ) + self.store.create_child(self.user_id, self.sequential.location, "vertical", "test_vertical") self.doc_sequential = { "id": "block-v1org1test_coursetest_runtypesequentialblocktest_sequential-f702c144", "type": "course_block", @@ -90,8 +98,8 @@ def setUp(self): ], "content": {}, "access_id": course_access.id, + "modified": modified_date.timestamp(), } - self.store.create_child(self.user_id, self.sequential.location, "vertical", "test_vertical") self.doc_vertical = { "id": "block-v1org1test_coursetest_runtypeverticalblocktest_vertical-e76a10a4", "type": "course_block", @@ -112,6 +120,7 @@ def setUp(self): ], "content": {}, "access_id": course_access.id, + "modified": modified_date.timestamp(), } # Make sure the CourseOverview for the course is created: CourseOverview.get_from_id(self.course.id) @@ -130,7 +139,6 @@ def setUp(self): self.problem1 = library_api.create_library_block(self.library.key, "problem", "p1") self.problem2 = library_api.create_library_block(self.library.key, "problem", "p2") # Update problem1, freezing the date so we can verify modified date serializes correctly. - modified_date = datetime(2024, 5, 6, 7, 8, 9, tzinfo=timezone.utc) with freeze_time(modified_date): library_api.set_library_block_olx(self.problem1.usage_key, "") @@ -230,6 +238,7 @@ def setUp(self): "display_name": "Unit 1", # description is not set for containers "num_children": 0, + "content": {"child_usage_keys": []}, "publish_status": "never", "context_key": "lib:org1:lib", "org": "org1", @@ -725,21 +734,6 @@ def test_index_content_library_metadata(self, mock_meilisearch): [self.doc_problem1, self.doc_problem2] ) - @override_settings(MEILISEARCH_ENABLED=True) - def test_delete_all_drafts(self, mock_meilisearch): - """ - Test deleting all draft documents from the index. - """ - api.delete_all_draft_docs_for_library(self.library.key) - - delete_filter = [ - f'context_key="{self.library.key}"', - ['last_published IS EMPTY', 'last_published IS NULL'], - ] - mock_meilisearch.return_value.index.return_value.delete_documents.assert_called_once_with( - filter=delete_filter - ) - @override_settings(MEILISEARCH_ENABLED=True) def test_index_tags_in_collections(self, mock_meilisearch): # Tag collection diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index 0a5d871fbb96..74772c89c017 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -52,23 +52,23 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): def setUpClass(cls): super().setUpClass() cls.store = modulestore() - cls.org = Organization.objects.create(name="edX", short_name="edX") - cls.toy_course = ToyCourseFactory.create() # See xmodule/modulestore/tests/sample_courses.py - cls.toy_course_key = cls.toy_course.id - - # Get references to some blocks in the toy course - cls.html_block_key = cls.toy_course_key.make_usage_key("html", "toyjumpto") - # Create a problem in course - cls.problem_block = BlockFactory.create( - category="problem", - parent_location=cls.toy_course_key.make_usage_key("vertical", "vertical_test"), - display_name='Test Problem', - data="What is a test?", - ) - # Create a library and collection with a block - created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) - with freeze_time(created_date): + cls.created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + with freeze_time(cls.created_date): + # Get references to some blocks in the toy course + cls.org = Organization.objects.create(name="edX", short_name="edX") + cls.toy_course = ToyCourseFactory.create() # See xmodule/modulestore/tests/sample_courses.py + cls.toy_course_key = cls.toy_course.id + + cls.html_block_key = cls.toy_course_key.make_usage_key("html", "toyjumpto") + # Create a problem in course + cls.problem_block = BlockFactory.create( + category="problem", + parent_location=cls.toy_course_key.make_usage_key("vertical", "vertical_test"), + display_name='Test Problem', + data="What is a test?", + ) + cls.library = library_api.create_library( org=cls.org, slug="2012_Fall", @@ -190,6 +190,7 @@ def test_problem_block(self): 'usage_key': 'block-v1:edX+toy+2012_Fall+type@vertical+block@vertical_test', }, ], + "modified": self.created_date.timestamp(), "content": { "capa_content": "What is a test?", "problem_types": ["multiplechoiceresponse"], @@ -223,6 +224,7 @@ def test_html_block(self): "display_name": "Text", "description": "This is a link to another page and some Chinese 四節比分和七年前 Some " "more Chinese 四節比分和七年前 ", + "modified": self.created_date.timestamp(), "breadcrumbs": [ { 'display_name': 'Toy Course', @@ -276,6 +278,7 @@ def test_video_block_untagged(self): }, ], "content": {}, + "modified": self.created_date.timestamp(), # This video has no tags. } @@ -528,6 +531,9 @@ def test_draft_container(self): "display_name": "A Unit in the Search Index", # description is not set for containers "num_children": 0, + "content": { + "child_usage_keys": [], + }, "publish_status": "never", "context_key": "lib:edX:2012_Fall", "access_id": self.library_access_id, @@ -568,6 +574,11 @@ def test_published_container(self): "display_name": "A Unit in the Search Index", # description is not set for containers "num_children": 1, + "content": { + "child_usage_keys": [ + "lb:edX:2012_Fall:html:text2", + ], + }, "publish_status": "published", "context_key": "lib:edX:2012_Fall", "access_id": self.library_access_id, @@ -582,6 +593,11 @@ def test_published_container(self): "published": { "num_children": 1, "display_name": "A Unit in the Search Index", + "content": { + "child_usage_keys": [ + "lb:edX:2012_Fall:html:text2", + ], + }, }, } @@ -624,6 +640,12 @@ def test_published_container_with_changes(self): "display_name": "A Unit in the Search Index", # description is not set for containers "num_children": 2, + "content": { + "child_usage_keys": [ + "lb:edX:2012_Fall:html:text2", + "lb:edX:2012_Fall:html:text3", + ], + }, "publish_status": "modified", "context_key": "lib:edX:2012_Fall", "access_id": self.library_access_id, @@ -638,6 +660,11 @@ def test_published_container_with_changes(self): "published": { "num_children": 1, "display_name": "A Unit in the Search Index", + "content": { + "child_usage_keys": [ + "lb:edX:2012_Fall:html:text2", + ], + }, }, } diff --git a/openedx/core/djangoapps/content/search/tests/test_handlers.py b/openedx/core/djangoapps/content/search/tests/test_handlers.py index 95b2ecb6f52a..33d0e4db8378 100644 --- a/openedx/core/djangoapps/content/search/tests/test_handlers.py +++ b/openedx/core/djangoapps/content/search/tests/test_handlers.py @@ -59,7 +59,9 @@ def test_create_delete_xblock(self, meilisearch_client): course_access, _ = SearchAccess.objects.get_or_create(context_key=course.id) # Create XBlocks - sequential = self.store.create_child(self.user_id, course.location, "sequential", "test_sequential") + created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + with freeze_time(created_date): + sequential = self.store.create_child(self.user_id, course.location, "sequential", "test_sequential") doc_sequential = { "id": "block-v1orgatest_coursetest_runtypesequentialblocktest_sequential-0cdb9395", "type": "course_block", @@ -76,10 +78,11 @@ def test_create_delete_xblock(self, meilisearch_client): ], "content": {}, "access_id": course_access.id, - + "modified": created_date.timestamp(), } meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_sequential]) - vertical = self.store.create_child(self.user_id, sequential.location, "vertical", "test_vertical") + with freeze_time(created_date): + vertical = self.store.create_child(self.user_id, sequential.location, "vertical", "test_vertical") doc_vertical = { "id": "block-v1orgatest_coursetest_runtypeverticalblocktest_vertical-011f143b", "type": "course_block", @@ -100,6 +103,7 @@ def test_create_delete_xblock(self, meilisearch_client): ], "content": {}, "access_id": course_access.id, + "modified": created_date.timestamp(), } meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_vertical]) @@ -107,11 +111,14 @@ def test_create_delete_xblock(self, meilisearch_client): # Update the XBlock sequential = self.store.get_item(sequential.location, self.user_id) # Refresh the XBlock sequential.display_name = "Updated Sequential" - self.store.update_item(sequential, self.user_id) + modified_date = datetime(2024, 5, 6, 7, 8, 9, tzinfo=timezone.utc) + with freeze_time(modified_date): + self.store.update_item(sequential, self.user_id) # The display name and the child's breadcrumbs should be updated doc_sequential["display_name"] = "Updated Sequential" doc_vertical["breadcrumbs"][1]["display_name"] = "Updated Sequential" + doc_sequential["modified"] = modified_date.timestamp() meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([ doc_sequential, doc_vertical, diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index 032d21431a3a..507822d3074e 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -26,8 +26,6 @@ class LibraryXBlockMetadata(PublishableItem): Class that represents the metadata about an XBlock in a content library. """ usage_key: LibraryUsageLocatorV2 - # TODO: move tags_count to LibraryItem as all objects under a library can be tagged. - tags_count: int = 0 @classmethod def from_component(cls, library_key, component, associated_collections=None): @@ -59,6 +57,7 @@ def from_component(cls, library_key, component, associated_collections=None): modified=draft.created, draft_version_num=draft.version_num, published_version_num=published.version_num if published else None, + published_display_name=published.title if published else None, last_published=None if last_publish_log is None else last_publish_log.published_at, published_by=published_by, last_draft_created=last_draft_created, diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index d440055448f2..d693ff30d7e5 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -63,10 +63,9 @@ ContainerMetadata, ContainerType, ) -from .libraries import ( - library_collection_locator, - PublishableItem, -) +from .collections import library_collection_locator +from .libraries import PublishableItem +from .. import tasks # This content_libraries API is sometimes imported in the LMS (should we prevent that?), but the content_staging app # cannot be. For now we only need this one type import at module scope, so only import it during type checks. @@ -836,24 +835,13 @@ def publish_component_changes(usage_key: LibraryUsageLocatorV2, user: UserType): # The core publishing API is based on draft objects, so find the draft that corresponds to this component: drafts_to_publish = authoring_api.get_all_drafts(learning_package.id).filter(entity__key=component.key) # Publish the component and update anything that needs to be updated (e.g. search index): - authoring_api.publish_from_drafts(learning_package.id, draft_qset=drafts_to_publish, published_by=user.id) - LIBRARY_BLOCK_UPDATED.send_event( - library_block=LibraryBlockData( - library_key=usage_key.lib_key, - usage_key=usage_key, - ) + publish_log = authoring_api.publish_from_drafts( + learning_package.id, draft_qset=drafts_to_publish, published_by=user.id, ) - - # For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger - # container indexing asynchronously. - affected_containers = get_containers_contains_component(usage_key) - for container in affected_containers: - LIBRARY_CONTAINER_UPDATED.send_event( - library_container=LibraryContainerData( - container_key=container.container_key, - background=True, - ) - ) + # Since this is a single component, it should be safe to process synchronously and in-process: + tasks.send_events_after_publish(publish_log.pk, str(library_key)) + # IF this is found to be a performance issue, we could instead make it async where necessary: + # tasks.wait_for_post_publish_events(publish_log, library_key=library_key) def _component_exists(usage_key: UsageKeyV2) -> bool: diff --git a/openedx/core/djangoapps/content_libraries/api/collections.py b/openedx/core/djangoapps/content_libraries/api/collections.py index 2b0ddc08d89c..da8b47ee5e75 100644 --- a/openedx/core/djangoapps/content_libraries/api/collections.py +++ b/openedx/core/djangoapps/content_libraries/api/collections.py @@ -181,7 +181,7 @@ def update_library_collection_items( def set_library_item_collections( library_key: LibraryLocatorV2, - publishable_entity: PublishableEntity, + entity_key: str, *, collection_keys: list[str], created_by: int | None = None, @@ -207,6 +207,11 @@ def set_library_item_collections( assert content_library.learning_package_id assert content_library.library_key == library_key + publishable_entity = authoring_api.get_publishable_entity_by_key( + content_library.learning_package_id, + key=entity_key, + ) + # Note: Component.key matches its PublishableEntity.key collection_qs = authoring_api.get_collections(content_library.learning_package_id).filter( key__in=collection_keys diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index d7ba0fcac01f..d97a6100a648 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from enum import Enum import logging from uuid import uuid4 @@ -14,13 +14,11 @@ from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_events.content_authoring.data import ( ContentObjectChangedData, - LibraryBlockData, LibraryCollectionData, LibraryContainerData, ) from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - LIBRARY_BLOCK_UPDATED, LIBRARY_COLLECTION_UPDATED, LIBRARY_CONTAINER_CREATED, LIBRARY_CONTAINER_DELETED, @@ -34,8 +32,9 @@ from ..models import ContentLibrary from .exceptions import ContentLibraryContainerNotFound -from .libraries import PublishableItem, library_component_usage_key +from .libraries import PublishableItem from .block_metadata import LibraryXBlockMetadata +from .. import tasks # The public API is only the following symbols: __all__ = [ @@ -43,7 +42,6 @@ "ContainerMetadata", "ContainerType", # API methods - "get_container_from_key", "get_container", "create_container", "get_container_children", @@ -111,7 +109,7 @@ class ContainerMetadata(PublishableItem): """ container_key: LibraryContainerLocator container_type: ContainerType - published_display_name: str | None + container_pk: int @classmethod def from_container(cls, library_key, container: Container, associated_collections=None): @@ -139,6 +137,7 @@ def from_container(cls, library_key, container: Container, associated_collection return cls( container_key=container_key, container_type=container_type, + container_pk=container.pk, display_name=draft.title, created=container.created, modified=draft.created, @@ -173,7 +172,7 @@ def library_container_locator( ) -def get_container_from_key(container_key: LibraryContainerLocator, isDeleted=False) -> Container: +def _get_container_from_key(container_key: LibraryContainerLocator, isDeleted=False) -> Container: """ Internal method to fetch the Container object from its LibraryContainerLocator @@ -192,11 +191,15 @@ def get_container_from_key(container_key: LibraryContainerLocator, isDeleted=Fal raise ContentLibraryContainerNotFound -def get_container(container_key: LibraryContainerLocator, include_collections=False) -> ContainerMetadata: +def get_container( + container_key: LibraryContainerLocator, + *, + include_collections=False, +) -> ContainerMetadata: """ Get a container (a Section, Subsection, or Unit). """ - container = get_container_from_key(container_key) + container = _get_container_from_key(container_key) if include_collections: associated_collections = authoring_api.get_entity_collections( container.publishable_entity.learning_package_id, @@ -245,7 +248,7 @@ def create_container( content_library.learning_package_id, key=slug, title=title, - created=created or datetime.now(), + created=created or datetime.now(tz=timezone.utc), created_by=user_id, ) case _: @@ -268,14 +271,14 @@ def update_container( """ Update a container (e.g. a Unit) title. """ - container = get_container_from_key(container_key) + container = _get_container_from_key(container_key) library_key = container_key.lib_key assert container.unit unit_version = authoring_api.create_next_unit_version( container.unit, title=display_name, - created=datetime.now(), + created=datetime.now(tz=timezone.utc), created_by=user_id, ) @@ -297,7 +300,7 @@ def delete_container( No-op if container doesn't exist or has already been soft-deleted. """ library_key = container_key.lib_key - container = get_container_from_key(container_key) + container = _get_container_from_key(container_key) affected_collections = authoring_api.get_entity_collections( container.publishable_entity.learning_package_id, @@ -332,7 +335,7 @@ def restore_container(container_key: LibraryContainerLocator) -> None: Restore the specified library container. """ library_key = container_key.lib_key - container = get_container_from_key(container_key, isDeleted=True) + container = _get_container_from_key(container_key, isDeleted=True) affected_collections = authoring_api.get_entity_collections( container.publishable_entity.learning_package_id, @@ -372,12 +375,13 @@ def restore_container(container_key: LibraryContainerLocator) -> None: def get_container_children( container_key: LibraryContainerLocator, + *, published=False, ) -> list[LibraryXBlockMetadata | ContainerMetadata]: """ Get the entities contained in the given container (e.g. the components/xblocks in a unit) """ - container = get_container_from_key(container_key) + container = _get_container_from_key(container_key) if container_key.container_type == ContainerType.Unit.value: child_components = authoring_api.get_components_in_unit(container.unit, published=published) return [LibraryXBlockMetadata.from_component( @@ -399,7 +403,7 @@ def get_container_children_count( """ Get the count of entities contained in the given container (e.g. the components/xblocks in a unit) """ - container = get_container_from_key(container_key) + container = _get_container_from_key(container_key) return authoring_api.get_container_children_count(container, published=published) @@ -414,14 +418,14 @@ def update_container_children( """ library_key = container_key.lib_key container_type = container_key.container_type - container = get_container_from_key(container_key) + container = _get_container_from_key(container_key) match container_type: case ContainerType.Unit.value: components = [get_component_from_usage_key(key) for key in children_ids] # type: ignore[arg-type] new_version = authoring_api.create_next_unit_version( container.unit, components=components, # type: ignore[arg-type] - created=datetime.now(), + created=datetime.now(tz=timezone.utc), created_by=user_id, entities_action=entities_action, ) @@ -459,7 +463,7 @@ def publish_container_changes(container_key: LibraryContainerLocator, user_id: i Publish all unpublished changes in a container and all its child containers/blocks. """ - container = get_container_from_key(container_key) + container = _get_container_from_key(container_key) library_key = container_key.lib_key content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] learning_package = content_library.learning_package @@ -472,21 +476,6 @@ def publish_container_changes(container_key: LibraryContainerLocator, user_id: i draft_qset=drafts_to_publish, published_by=user_id, ) - # Update anything that needs to be updated (e.g. search index): - for record in publish_log.records.select_related("entity", "entity__container", "entity__component").all(): - if hasattr(record.entity, "component"): - # This is a child component like an XBLock in a Unit that was published: - usage_key = library_component_usage_key(library_key, record.entity.component) - LIBRARY_BLOCK_UPDATED.send_event( - library_block=LibraryBlockData(library_key=library_key, usage_key=usage_key) - ) - elif hasattr(record.entity, "container"): - # This is a child container like a Unit, or is the same "container" we published above. - LIBRARY_CONTAINER_UPDATED.send_event( - library_container=LibraryContainerData(container_key=container_key) - ) - else: - log.warning( - f"PublishableEntity {record.entity.pk} / {record.entity.key} was modified during publish operation " - "but is of unknown type." - ) + # Update the search index (and anything else) for the affected container + blocks + # This is mostly synchronous but may complete some work asynchronously if there are a lot of changes. + tasks.wait_for_post_publish_events(publish_log, library_key) diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index 8e238f278096..290c88a16a64 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -55,15 +55,11 @@ from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_events.content_authoring.data import ( ContentLibraryData, - LibraryCollectionData, - ContentObjectChangedData, ) from openedx_events.content_authoring.signals import ( CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_UPDATED, - LIBRARY_COLLECTION_UPDATED, - CONTENT_OBJECT_ASSOCIATIONS_CHANGED, ) from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import Component @@ -75,7 +71,7 @@ from .. import permissions from ..constants import ALL_RIGHTS_RESERVED from ..models import ContentLibrary, ContentLibraryPermission -from .collections import library_collection_locator +from .. import tasks from .exceptions import ( LibraryAlreadyExists, LibraryPermissionIntegrityError, @@ -184,6 +180,7 @@ class LibraryItem: created: datetime modified: datetime display_name: str + tags_count: int = 0 @dataclass(frozen=True, kw_only=True) @@ -194,6 +191,7 @@ class PublishableItem(LibraryItem): """ draft_version_num: int published_version_num: int | None = None + published_display_name: str | None last_published: datetime | None = None # The username of the user who last published this. published_by: str = "" @@ -663,14 +661,15 @@ def publish_changes(library_key: LibraryLocatorV2, user_id: int | None = None): """ learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package assert learning_package is not None # shouldn't happen but it's technically possible. - authoring_api.publish_all_drafts(learning_package.id, published_by=user_id) + publish_log = authoring_api.publish_all_drafts(learning_package.id, published_by=user_id) - CONTENT_LIBRARY_UPDATED.send_event( - content_library=ContentLibraryData( - library_key=library_key, - update_blocks=True - ) - ) + # Update the search index (and anything else) for the affected blocks + # This is mostly synchronous but may complete some work asynchronously if there are a lot of changes. + tasks.wait_for_post_publish_events(publish_log, library_key) + + # Unlike revert_changes below, we do not have to re-index collections, + # because publishing changes does not affect the component counts, and + # collections themselves don't have draft/published/unpublished status. def revert_changes(library_key: LibraryLocatorV2, user_id: int | None = None) -> None: @@ -680,46 +679,8 @@ def revert_changes(library_key: LibraryLocatorV2, user_id: int | None = None) -> """ learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package assert learning_package is not None # shouldn't happen but it's technically possible. - authoring_api.reset_drafts_to_published(learning_package.id, reset_by=user_id) - - CONTENT_LIBRARY_UPDATED.send_event( - content_library=ContentLibraryData( - library_key=library_key, - update_blocks=True - ) - ) - - # For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger - # collection indexing asynchronously. - # - # This is to update component counts in all library collections, - # because there may be components that have been discarded in the revert. - for collection in authoring_api.get_collections(learning_package.id): - LIBRARY_COLLECTION_UPDATED.send_event( - library_collection=LibraryCollectionData( - collection_key=library_collection_locator( - library_key=library_key, - collection_key=collection.key, - ), - background=True, - ) - ) + with authoring_api.bulk_draft_changes_for(learning_package.id) as draft_change_log: + authoring_api.reset_drafts_to_published(learning_package.id, reset_by=user_id) - # Reindex components that are in collections - # - # Use case: When a component that was within a collection has been deleted - # and the changes are reverted, the component should appear in the - # collection again. - components_in_collections = authoring_api.get_components( - learning_package.id, draft=True, namespace='xblock.v1', - ).filter(publishable_entity__collections__isnull=False) - - for component in components_in_collections: - usage_key = library_component_usage_key(library_key, component) - - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( - content_object=ContentObjectChangedData( - object_id=str(usage_key), - changes=["collections"], - ), - ) + # Call the event handlers as needed. + tasks.wait_for_post_revert_events(draft_change_log, library_key) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index 6ab35c47c632..bc314099893c 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -265,14 +265,14 @@ def patch(self, request: RestRequest, usage_key_str) -> Response: request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY ) - component = api.get_component_from_usage_key(key) serializer = ContentLibraryItemCollectionsUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) + component = api.get_component_from_usage_key(key) collection_keys = serializer.validated_data['collection_keys'] api.set_library_item_collections( library_key=key.lib_key, - publishable_entity=component.publishable_entity, + entity_key=component.publishable_entity.key, collection_keys=collection_keys, created_by=request.user.id, content_library=content_library, diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index 061f4a0693d1..06baa6fc4254 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -178,13 +178,13 @@ def get(self, request, container_key: LibraryContainerLocator): } ] """ - published = request.GET.get('published', False) + published = request.GET.get('published', 'false').lower() == 'true' api.require_permission_for_library_key( container_key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, ) - child_entities = api.get_container_children(container_key, published) + child_entities = api.get_container_children(container_key, published=published) if container_key.container_type == api.ContainerType.Unit.value: data = serializers.LibraryXBlockMetadataSerializer(child_entities, many=True).data else: @@ -314,14 +314,13 @@ def patch(self, request: RestRequest, container_key: LibraryContainerLocator) -> request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY ) - container = api.get_container_from_key(container_key) serializer = serializers.ContentLibraryItemCollectionsUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) collection_keys = serializer.validated_data['collection_keys'] api.set_library_item_collections( library_key=container_key.lib_key, - publishable_entity=container.publishable_entity, + entity_key=container_key.container_id, collection_keys=collection_keys, created_by=request.user.id, content_library=content_library, diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index be386e0e9dfe..38765f0b320f 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -138,6 +138,8 @@ class PublishableItemSerializer(serializers.Serializer): """ id = serializers.SerializerMethodField() display_name = serializers.CharField() + published_display_name = serializers.CharField(required=False) + tags_count = serializers.IntegerField(read_only=True) last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) published_by = serializers.CharField(read_only=True) last_draft_created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) @@ -149,7 +151,6 @@ class PublishableItemSerializer(serializers.Serializer): # When creating a new XBlock in a library, the slug becomes the ID part of # the definition key and usage key: slug = serializers.CharField(write_only=True) - tags_count = serializers.IntegerField(read_only=True) collections = CollectionMetadataSerializer(many=True, required=False) can_stand_alone = serializers.BooleanField(read_only=True) diff --git a/openedx/core/djangoapps/content_libraries/tasks.py b/openedx/core/djangoapps/content_libraries/tasks.py index b76101e1c62e..b472126e8ce7 100644 --- a/openedx/core/djangoapps/content_libraries/tasks.py +++ b/openedx/core/djangoapps/content_libraries/tasks.py @@ -23,11 +23,34 @@ from celery.utils.log import get_task_logger from edx_django_utils.monitoring import set_code_owner_attribute, set_code_owner_attribute_from_module from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import ( + BlockUsageLocator, + LibraryCollectionLocator, + LibraryContainerLocator, + LibraryLocatorV2, +) +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import DraftChangeLog, PublishLog +from openedx_events.content_authoring.data import ( + LibraryBlockData, + LibraryCollectionData, + LibraryContainerData, +) +from openedx_events.content_authoring.signals import ( + LIBRARY_BLOCK_CREATED, + LIBRARY_BLOCK_DELETED, + LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, + LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, +) from user_tasks.tasks import UserTask, UserTaskStatus from xblock.fields import Scope -from opaque_keys.edx.locator import BlockUsageLocator from openedx.core.lib import ensure_cms from xmodule.capa_block import ProblemBlock from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LegacyLibraryContentBlock @@ -39,10 +62,197 @@ from . import api from .models import ContentLibraryBlockImportTask -logger = logging.getLogger(__name__) +log = logging.getLogger(__name__) TASK_LOGGER = get_task_logger(__name__) +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def send_events_after_publish(publish_log_pk: int, library_key_str: str) -> None: + """ + Send events to trigger actions like updating the search index, after we've + published some items in a library. + + We use the PublishLog record so we can detect exactly what was changed, + including any auto-published changes like child items in containers. + + This happens in a celery task so that it can be run asynchronously if + needed, because the "publish all changes" action can potentially publish + hundreds or even thousands of components/containers at once, and synchronous + event handlers like updating the search index may a while to complete in + that case. + """ + publish_log = PublishLog.objects.get(pk=publish_log_pk) + library_key = LibraryLocatorV2.from_string(library_key_str) + affected_entities = publish_log.records.select_related("entity", "entity__container", "entity__component").all() + affected_containers: set[LibraryContainerLocator] = set() + + # Update anything that needs to be updated (e.g. search index): + for record in affected_entities: + if hasattr(record.entity, "component"): + usage_key = api.library_component_usage_key(library_key, record.entity.component) + # Note that this item may be newly created, updated, or even deleted - but all we care about for this event + # is that the published version is now different. Only for draft changes do we send differentiated events. + LIBRARY_BLOCK_PUBLISHED.send_event( + library_block=LibraryBlockData(library_key=library_key, usage_key=usage_key) + ) + # Publishing a container will auto-publish its children, but publishing a single component or all changes + # in the library will NOT usually include any parent containers. But we do need to notify listeners that the + # parent container(s) have changed, e.g. so the search index can update the "has_unpublished_changes" + for parent_container in api.get_containers_contains_component(usage_key): + affected_containers.add(parent_container.container_key) + # TODO: should this be a CONTAINER_CHILD_PUBLISHED event instead of CONTAINER_PUBLISHED ? + elif hasattr(record.entity, "container"): + container_key = api.library_container_locator(library_key, record.entity.container) + affected_containers.add(container_key) + else: + log.warning( + f"PublishableEntity {record.entity.pk} / {record.entity.key} was modified during publish operation " + "but is of unknown type." + ) + + for container_key in affected_containers: + LIBRARY_CONTAINER_PUBLISHED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + + +def wait_for_post_publish_events(publish_log: PublishLog, library_key: LibraryLocatorV2): + """ + After publishing some changes, trigger the required event handlers (e.g. + update the search index). Try to wait for that to complete before returning, + up to some reasonable timeout, and then finish anything remaining + asynchonrously. + """ + # Update the search index (and anything else) for the affected blocks + result = send_events_after_publish.apply_async(args=(publish_log.pk, str(library_key))) + # Try waiting a bit for those post-publish events to be handled: + try: + result.get(timeout=15) + except TimeoutError: + pass + # This is fine! The search index is still being updated, and/or other + # event handlers are still following up on the results, but the publish + # already *did* succeed, and the events will continue to be processed in + # the background by the celery worker until everything is updated. + + +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def send_events_after_revert(draft_change_log_id: int, library_key_str: str) -> None: + """ + Send events to trigger actions like updating the search index, after we've + reverted some unpublished changes in a library. + + See notes on the analogous function above, send_events_after_publish. + """ + try: + draft_change_log = DraftChangeLog.objects.get(id=draft_change_log_id) + except DraftChangeLog.DoesNotExist: + # When a revert operation is a no-op, Learning Core deletes the empty + # DraftChangeLog, so we'll assume that's what happened here. + log.info(f"Library revert in {library_key_str} did not result in any changes.") + return + + library_key = LibraryLocatorV2.from_string(library_key_str) + affected_entities = draft_change_log.records.select_related( + "entity", "entity__container", "entity__component", + ).all() + + created_container_keys: set[LibraryContainerLocator] = set() + updated_container_keys: set[LibraryContainerLocator] = set() + deleted_container_keys: set[LibraryContainerLocator] = set() + affected_collection_keys: set[LibraryCollectionLocator] = set() + + # Update anything that needs to be updated (e.g. search index): + for record in affected_entities: + # This will be true if the entity was [soft] deleted, but we're now reverting that deletion: + is_undeleted = (record.old_version is None and record.new_version is not None) + # This will be true if the entity was created and we're now deleting it by reverting that creation: + is_deleted = (record.old_version is not None and record.new_version is None) + if hasattr(record.entity, "component"): + usage_key = api.library_component_usage_key(library_key, record.entity.component) + event = LIBRARY_BLOCK_UPDATED + if is_deleted: + event = LIBRARY_BLOCK_DELETED + elif is_undeleted: + event = LIBRARY_BLOCK_CREATED + event.send_event(library_block=LibraryBlockData(library_key=library_key, usage_key=usage_key)) + # If any containers contain this component, their child list / component count may need to be updated + # e.g. if this was a newly created component in the container and is now deleted, or this was deleted and + # is now restored. + for parent_container in api.get_containers_contains_component(usage_key): + updated_container_keys.add(parent_container.container_key) + + # TODO: do we also need to send CONTENT_OBJECT_ASSOCIATIONS_CHANGED for this component, or is + # LIBRARY_BLOCK_UPDATED sufficient? + elif hasattr(record.entity, "container"): + container_key = api.library_container_locator(library_key, record.entity.container) + if is_deleted: + deleted_container_keys.add(container_key) + elif is_undeleted: + created_container_keys.add(container_key) + else: + updated_container_keys.add(container_key) + else: + log.warning( + f"PublishableEntity {record.entity.pk} / {record.entity.key} was modified during publish operation " + "but is of unknown type." + ) + # If any collections contain this entity, their item count may need to be updated, e.g. if this was a + # newly created component in the collection and is now deleted, or this was deleted and is now re-added. + for parent_collection in authoring_api.get_entity_collections( + record.entity.learning_package_id, record.entity.key, + ): + collection_key = api.library_collection_locator( + library_key=library_key, + collection_key=parent_collection.key, + ) + affected_collection_keys.add(collection_key) + + for container_key in deleted_container_keys: + LIBRARY_CONTAINER_DELETED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + # Don't bother sending UPDATED events for these containers that are now deleted + created_container_keys.discard(container_key) + + for container_key in created_container_keys: + LIBRARY_CONTAINER_CREATED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + + for container_key in updated_container_keys: + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + + for collection_key in affected_collection_keys: + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData(collection_key=collection_key) + ) + + +def wait_for_post_revert_events(draft_change_log: DraftChangeLog, library_key: LibraryLocatorV2): + """ + After discard all changes in a library, trigger the required event handlers + (e.g. update the search index). Try to wait for that to complete before + returning, up to some reasonable timeout, and then finish anything remaining + asynchonrously. + """ + # Update the search index (and anything else) for the affected blocks + result = send_events_after_revert.apply_async(args=(draft_change_log.pk, str(library_key))) + # Try waiting a bit for those post-publish events to be handled: + try: + result.get(timeout=15) + except TimeoutError: + pass + # This is fine! The search index is still being updated, and/or other + # event handlers are still following up on the results, but the revert + # already *did* succeed, and the events will continue to be processed in + # the background by the celery worker until everything is updated. + + @shared_task(base=LoggedTask) @set_code_owner_attribute def import_blocks_from_course(import_task_id, course_key_str, use_course_key_as_block_id_suffix=True): @@ -57,9 +267,9 @@ def import_blocks_from_course(import_task_id, course_key_str, use_course_key_as_ def on_progress(block_key, block_num, block_count, exception=None): if exception: - logger.exception('Import block failed: %s', block_key) + log.exception('Import block failed: %s', block_key) else: - logger.info('Import block succesful: %s', block_key) + log.info('Import block succesful: %s', block_key) import_task.save_progress(block_num / block_count) edx_client = api.EdxModulestoreImportClient( @@ -121,6 +331,9 @@ def sync_from_library( ) -> None: """ Celery task to update the children of the library_content block at `dest_block_id`. + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ set_code_owner_attribute_from_module(__name__) store = modulestore() @@ -143,6 +356,9 @@ def duplicate_children( ) -> None: """ Celery task to duplicate the children from `source_block_id` to `dest_block_id`. + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ set_code_owner_attribute_from_module(__name__) store = modulestore() @@ -180,6 +396,9 @@ def _sync_children( Implementation helper for `sync_from_library` and `duplicate_children` Celery tasks. Can update children with a specific library `library_version`, or latest (`library_version=None`). + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ source_blocks = [] library_key = dest_block.source_library_key.for_branch( @@ -220,6 +439,9 @@ def _copy_overrides( ) -> None: """ Copy any overrides the user has made on children of `source` over to the children of `dest_block`, recursively. + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ for field in source_block.fields.values(): if field.scope == Scope.settings and field.is_set_on(source_block): diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 6068d9c20e72..e5a9f5f12ec9 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -8,6 +8,8 @@ from organizations.models import Organization from rest_framework.test import APITransactionTestCase, APIClient +from opaque_keys.edx.keys import ContainerKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryCollectionLocator from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.json_request import JsonResponse as SpecialJsonResponse @@ -25,6 +27,7 @@ URL_LIB_COMMIT = URL_LIB_DETAIL + 'commit/' # Commit (POST) or revert (DELETE) all pending changes to this library URL_LIB_BLOCKS = URL_LIB_DETAIL + 'blocks/' # Get the list of XBlocks in this library, or add a new one URL_LIB_CONTAINERS = URL_LIB_DETAIL + 'containers/' # Create a new container in this library +URL_LIB_COLLECTIONS = URL_LIB_DETAIL + 'collections/' # Create a new collection in this library URL_LIB_TEAM = URL_LIB_DETAIL + 'team/' # Get the list of users/groups authorized to use this library URL_LIB_TEAM_USER = URL_LIB_TEAM + 'user/{username}/' # Add/edit/remove a user's permission to use this library URL_LIB_TEAM_GROUP = URL_LIB_TEAM + 'group/{group_name}/' # Add/edit/remove a group's permission to use this library @@ -39,6 +42,8 @@ URL_LIB_CONTAINER_RESTORE = URL_LIB_CONTAINER + 'restore/' # Restore a deleted container URL_LIB_CONTAINER_COLLECTIONS = URL_LIB_CONTAINER + 'collections/' # Handle associated collections URL_LIB_CONTAINER_PUBLISH = URL_LIB_CONTAINER + 'publish/' # Publish changes to the specified container + children +URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' # Get a collection in this library +URL_LIB_COLLECTION_ITEMS = URL_LIB_COLLECTION + 'items/' # Get a collection in this library URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/' URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/' @@ -70,11 +75,6 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): entire response has some specific shape. That way, things like adding new fields to an API response, which are backwards compatible, won't break any tests, but backwards-incompatible API changes will. - - WARNING: every test should have a unique library slug, because even though - the django/mysql database gets reset for each test case, the lookup between - library slug and bundle UUID does not because it's assumed to be immutable - and cached forever. """ def setUp(self): @@ -379,24 +379,24 @@ def _create_container(self, lib_key, container_type, slug: str | None, display_n data["slug"] = slug return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), data, expect_response) - def _get_container(self, container_key: str, expect_response=200): + def _get_container(self, container_key: ContainerKey | str, expect_response=200): """ Get a container (unit etc.) """ return self._api('get', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response) - def _update_container(self, container_key: str, display_name: str, expect_response=200): + def _update_container(self, container_key: ContainerKey | str, display_name: str, expect_response=200): """ Update a container (unit etc.) """ data = {"display_name": display_name} return self._api('patch', URL_LIB_CONTAINER.format(container_key=container_key), data, expect_response) - def _delete_container(self, container_key: str, expect_response=204): + def _delete_container(self, container_key: ContainerKey | str, expect_response=204): """ Delete a container (unit etc.) """ return self._api('delete', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response) - def _restore_container(self, container_key: str, expect_response=204): + def _restore_container(self, container_key: ContainerKey | str, expect_response=204): """ Restore a deleted a container (unit etc.) """ return self._api('post', URL_LIB_CONTAINER_RESTORE.format(container_key=container_key), None, expect_response) - def _get_container_components(self, container_key: str, expect_response=200): + def _get_container_components(self, container_key: ContainerKey | str, expect_response=200): """ Get container components""" return self._api( 'get', @@ -407,7 +407,7 @@ def _get_container_components(self, container_key: str, expect_response=200): def _add_container_components( self, - container_key: str, + container_key: ContainerKey | str, children_ids: list[str], expect_response=200, ): @@ -421,7 +421,7 @@ def _add_container_components( def _remove_container_components( self, - container_key: str, + container_key: ContainerKey | str, children_ids: list[str], expect_response=200, ): @@ -435,7 +435,7 @@ def _remove_container_components( def _patch_container_components( self, - container_key: str, + container_key: ContainerKey | str, children_ids: list[str], expect_response=200, ): @@ -449,7 +449,7 @@ def _patch_container_components( def _patch_container_collections( self, - container_key: str, + container_key: ContainerKey | str, collection_keys: list[str], expect_response=200, ): @@ -461,6 +461,52 @@ def _patch_container_collections( expect_response ) - def _publish_container(self, container_key, expect_response=200): + def _publish_container(self, container_key: ContainerKey | str, expect_response=200): """ Publish all changes in the specified container + children """ return self._api('post', URL_LIB_CONTAINER_PUBLISH.format(container_key=container_key), None, expect_response) + + def _create_collection( + self, + lib_key: LibraryLocatorV2 | str, + title: str, + description: str = "", + expect_response=200, + ): + """ Create a new collection in this library """ + data = {"title": title, "description": description} + return self._api('post', URL_LIB_COLLECTIONS.format(lib_key=lib_key), data, expect_response) + + def _soft_delete_collection(self, collection_key: LibraryCollectionLocator, expect_response=204): + """ Soft delete (disable) a collection """ + url = URL_LIB_COLLECTION.format(lib_key=collection_key.lib_key, collection_key=collection_key.collection_id) + return self._api('delete', url, {}, expect_response) + + def _update_collection( + self, + collection_key: LibraryCollectionLocator, + title: str | None = None, + description: str | None = None, + expect_response=200, + ): + """ Update a collection's title/description """ + data = {} + if title is not None: + data["title"] = title + if description is not None: + data["description"] = description + url = URL_LIB_COLLECTION.format(lib_key=collection_key.lib_key, collection_key=collection_key.collection_id) + return self._api('patch', url, data, expect_response) + + def _add_items_to_collection( + self, + collection_key: LibraryCollectionLocator, + item_keys: list[str | UsageKey | ContainerKey], + expect_response=200, + ): + """ Add components/containers to a collection """ + data = {"usage_keys": [str(k) for k in item_keys]} + url = URL_LIB_COLLECTION_ITEMS.format( + lib_key=collection_key.lib_key, + collection_key=collection_key.collection_id, + ) + return self._api('patch', url, data, expect_response) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index 8f79ec7f6339..3a1121da38c2 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -25,7 +25,6 @@ LIBRARY_COLLECTION_UPDATED, LIBRARY_CONTAINER_UPDATED, ) -from openedx_events.tests.utils import OpenEdxEventsTestMixin from openedx_learning.api import authoring as authoring_api from .. import api @@ -259,30 +258,12 @@ def test_import_block_when_url_is_from_studio( mock_publish_changes.assert_not_called() -class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest): """ Tests for Content Library API collections methods. Same guidelines as ContentLibrariesTestCase. """ - ENABLED_OPENEDX_EVENTS = [ - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.event_type, - LIBRARY_COLLECTION_CREATED.event_type, - LIBRARY_COLLECTION_DELETED.event_type, - LIBRARY_COLLECTION_UPDATED.event_type, - ] - - @classmethod - def setUpClass(cls): - """ - Set up class method for the Test class. - - TODO: It's unclear why we need to call start_events_isolation ourselves rather than relying on - OpenEdxEventsTestMixin.setUpClass to handle it. It fails it we don't, and many other test cases do it, - so we're following a pattern here. But that pattern doesn't really make sense. - """ - super().setUpClass() - cls.start_events_isolation() def setUp(self): super().setUp() @@ -547,54 +528,36 @@ def test_set_library_component_collections(self): LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver) assert not list(self.col2.entities.all()) component = api.get_component_from_usage_key(UsageKey.from_string(self.lib2_problem_block["id"])) - api.set_library_item_collections( - self.lib2.library_key, - component.publishable_entity, + library_key=self.lib2.library_key, + entity_key=component.publishable_entity.key, collection_keys=[self.col2.key, self.col3.key], ) assert len(authoring_api.get_collection(self.lib2.learning_package_id, self.col2.key).entities.all()) == 1 assert len(authoring_api.get_collection(self.lib2.learning_package_id, self.col3.key).entities.all()) == 1 - self.assertDictContainsSubset( - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=self.lib2_problem_block["id"], - changes=["collections"], - ), - }, - event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": LIBRARY_COLLECTION_UPDATED, - "sender": None, - "library_collection": LibraryCollectionData( - collection_key=api.library_collection_locator( - self.lib2.library_key, - collection_key=self.col2.key, - ), - background=True, - ), - }, - collection_update_event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": LIBRARY_COLLECTION_UPDATED, - "sender": None, - "library_collection": LibraryCollectionData( - collection_key=api.library_collection_locator( - self.lib2.library_key, - collection_key=self.col3.key, - ), - background=True, - ), - }, - collection_update_event_receiver.call_args_list[1].kwargs, - ) + assert { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=self.lib2_problem_block["id"], + changes=["collections"], + ), + }.items() <= event_receiver.call_args_list[0].kwargs.items() + + assert len(collection_update_event_receiver.call_args_list) == 2 + collection_update_events = [call.kwargs for call in collection_update_event_receiver.call_args_list] + assert all(event["signal"] == LIBRARY_COLLECTION_UPDATED for event in collection_update_events) + assert {event["library_collection"] for event in collection_update_events} == { + LibraryCollectionData( + collection_key=api.library_collection_locator(self.lib2.library_key, collection_key=self.col2.key), + background=True, + ), + LibraryCollectionData( + collection_key=api.library_collection_locator(self.lib2.library_key, collection_key=self.col3.key), + background=True, + ) + } def test_delete_library_block(self): api.update_library_collection_items( @@ -691,72 +654,46 @@ def test_restore_library_block(self): ) def test_add_component_and_revert(self): - # Add component and publish - api.update_library_collection_items( - self.lib1.library_key, - self.col1.key, - opaque_keys=[ - UsageKey.from_string(self.lib1_problem_block["id"]), - ], - ) + # Publish changes api.publish_changes(self.lib1.library_key) - # Add component and revert + # Create a new component that will only exist as a draft + new_problem_block = self._add_block_to_library( + self.lib1.library_key, "problem", "problemNEW", + ) + + # Add component. Note: collections are not part of the draft/publish cycle so this is not a draft change. api.update_library_collection_items( self.lib1.library_key, self.col1.key, opaque_keys=[ UsageKey.from_string(self.lib1_html_block["id"]), + UsageKey.from_string(new_problem_block["id"]), ], ) - event_receiver = mock.Mock() - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver) collection_update_event_receiver = mock.Mock() LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver) api.revert_changes(self.lib1.library_key) assert collection_update_event_receiver.call_count == 1 - assert event_receiver.call_count == 2 - self.assertDictContainsSubset( - { - "signal": LIBRARY_COLLECTION_UPDATED, - "sender": None, - "library_collection": LibraryCollectionData( - collection_key=api.library_collection_locator( - self.lib1.library_key, - collection_key=self.col1.key, - ), - background=True, - ), - }, - collection_update_event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(self.lib1_problem_block["id"]), - changes=["collections"], + assert { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key=self.col1.key, ), - }, - event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(self.lib1_html_block["id"]), - changes=["collections"], - ), - }, - event_receiver.call_args_list[1].kwargs, - ) + ), + }.items() <= collection_update_event_receiver.call_args_list[0].kwargs.items() def test_delete_component_and_revert(self): + """ + When a component is deleted and then the delete is reverted, signals + will be emitted to update any containing collections. + """ # Add components and publish api.update_library_collection_items( self.lib1.library_key, @@ -771,72 +708,28 @@ def test_delete_component_and_revert(self): # Delete component and revert api.delete_library_block(UsageKey.from_string(self.lib1_problem_block["id"])) - event_receiver = mock.Mock() - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver) collection_update_event_receiver = mock.Mock() LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver) api.revert_changes(self.lib1.library_key) assert collection_update_event_receiver.call_count == 1 - assert event_receiver.call_count == 2 - self.assertDictContainsSubset( - { - "signal": LIBRARY_COLLECTION_UPDATED, - "sender": None, - "library_collection": LibraryCollectionData( - collection_key=api.library_collection_locator( - self.lib1.library_key, - collection_key=self.col1.key, - ), - background=True, - ), - }, - collection_update_event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(self.lib1_problem_block["id"]), - changes=["collections"], + assert { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key=self.col1.key, ), - }, - event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(self.lib1_html_block["id"]), - changes=["collections"], - ), - }, - event_receiver.call_args_list[1].kwargs, - ) + ), + }.items() <= collection_update_event_receiver.call_args_list[0].kwargs.items() -class ContentLibraryContainersTest(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class ContentLibraryContainersTest(ContentLibrariesRestApiTest): """ Tests for Content Library API containers methods. """ - ENABLED_OPENEDX_EVENTS = [ - LIBRARY_CONTAINER_UPDATED.event_type, - ] - - @classmethod - def setUpClass(cls): - """ - Set up class method for the Test class. - - TODO: It's unclear why we need to call start_events_isolation ourselves rather than relying on - OpenEdxEventsTestMixin.setUpClass to handle it. It fails it we don't, and many other test cases do it, - so we're following a pattern here. But that pattern doesn't really make sense. - """ - super().setUpClass() - cls.start_events_isolation() def setUp(self): super().setUp() @@ -945,3 +838,29 @@ def test_call_container_update_signal_when_update_component(self): self._set_library_block_fields(self.html_block_usage_key, {"data": block_olx, "metadata": {}}) self._validate_calls_of_html_block(container_update_event_receiver) + + def test_delete_component_and_revert(self): + """ + When a component is deleted and then the delete is reverted, signals + will be emitted to update any containing containers. + """ + # Add components and publish + api.update_container_children(self.unit1.container_key, [ + UsageKey.from_string(self.problem_block["id"]), + ], user_id=None) + api.publish_changes(self.lib1.library_key) + + # Delete component and revert + api.delete_library_block(UsageKey.from_string(self.problem_block["id"])) + + container_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_event_receiver) + + api.revert_changes(self.lib1.library_key) + + assert container_event_receiver.call_count == 1 + assert { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData(container_key=self.unit1.container_key), + }.items() <= container_event_receiver.call_args_list[0].kwargs.items() diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index db6456a14a20..6c59c8c086e4 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -2,20 +2,11 @@ Tests for Learning-Core-based Content Libraries """ from datetime import datetime, timezone -from unittest import mock import ddt from freezegun import freeze_time -from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 -from openedx_events.content_authoring.data import LibraryContainerData -from openedx_events.content_authoring.signals import ( - LIBRARY_BLOCK_UPDATED, - LIBRARY_CONTAINER_CREATED, - LIBRARY_CONTAINER_DELETED, - LIBRARY_CONTAINER_UPDATED, -) -from openedx_events.tests.utils import OpenEdxEventsTestMixin +from opaque_keys.edx.locator import LibraryLocatorV2 from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.content_libraries import api @@ -25,7 +16,7 @@ @skip_unless_cms @ddt.ddt -class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): +class ContainersTestCase(ContentLibrariesRestApiTest): """ Tests for containers (Sections, Subsections, Units) in Content Libraries. @@ -43,12 +34,6 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): new fields to an API response, which are backwards compatible, won't break any tests, but backwards-incompatible API changes will. """ - ENABLED_OPENEDX_EVENTS = [ - LIBRARY_BLOCK_UPDATED.event_type, - LIBRARY_CONTAINER_CREATED.event_type, - LIBRARY_CONTAINER_DELETED.event_type, - LIBRARY_CONTAINER_UPDATED.event_type, - ] def test_unit_crud(self): """ @@ -57,15 +42,6 @@ def test_unit_crud(self): lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") lib_key = LibraryLocatorV2.from_string(lib["id"]) - create_receiver = mock.Mock() - LIBRARY_CONTAINER_CREATED.connect(create_receiver) - - update_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(update_receiver) - - delete_receiver = mock.Mock() - LIBRARY_CONTAINER_DELETED.connect(delete_receiver) - # Create a unit: create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc) with freeze_time(create_date): @@ -85,20 +61,6 @@ def test_unit_crud(self): } self.assertDictContainsEntries(container_data, expected_data) - assert create_receiver.call_count == 1 - container_key = LibraryContainerLocator.from_string( - "lct:CL-TEST:containers:unit:u1", - ) - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_CREATED, - "sender": None, - "library_container": LibraryContainerData( - container_key, - ), - }, - create_receiver.call_args_list[0].kwargs, - ) # Fetch the unit: unit_as_read = self._get_container(container_data["id"]) @@ -113,18 +75,6 @@ def test_unit_crud(self): expected_data['display_name'] = 'Unit ABC' self.assertDictContainsEntries(container_data, expected_data) - assert update_receiver.call_count == 1 - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key, - ), - }, - update_receiver.call_args_list[0].kwargs, - ) - # Re-fetch the unit unit_as_re_read = self._get_container(container_data["id"]) # make sure it contains the same data when we read it back: @@ -133,17 +83,6 @@ def test_unit_crud(self): # Delete the unit self._delete_container(container_data["id"]) self._get_container(container_data["id"], expect_response=404) - assert delete_receiver.call_count == 1 - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_DELETED, - "sender": None, - "library_container": LibraryContainerData( - container_key, - ), - }, - delete_receiver.call_args_list[0].kwargs, - ) def test_unit_permissions(self): """ @@ -186,8 +125,6 @@ def test_unit_add_children(self): """ Test that we can add and get unit children components """ - update_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(update_receiver) lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") lib_key = LibraryLocatorV2.from_string(lib["id"]) @@ -212,18 +149,6 @@ def test_unit_add_children(self): container_data["id"], children_ids=[problem_block_2["id"], html_block_2["id"]] ) - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=LibraryContainerLocator.from_string( - container_data["id"], - ), - ), - }, - update_receiver.call_args_list[0].kwargs, - ) data = self._get_container_components(container_data["id"]) # Verify total number of components to be 2 + 2 = 4 assert len(data) == 4 @@ -236,8 +161,6 @@ def test_unit_remove_children(self): """ Test that we can remove unit children components """ - update_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(update_receiver) lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") lib_key = LibraryLocatorV2.from_string(lib["id"]) @@ -262,25 +185,11 @@ def test_unit_remove_children(self): assert len(data) == 2 assert data[0]['id'] == html_block['id'] assert data[1]['id'] == html_block_2['id'] - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=LibraryContainerLocator.from_string( - container_data["id"], - ), - ), - }, - update_receiver.call_args_list[0].kwargs, - ) def test_unit_replace_children(self): """ Test that we can completely replace/reorder unit children components. """ - update_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(update_receiver) lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") lib_key = LibraryLocatorV2.from_string(lib["id"]) @@ -324,18 +233,6 @@ def test_unit_replace_children(self): assert len(data) == 2 assert data[0]['id'] == new_problem_block['id'] assert data[1]['id'] == new_html_block['id'] - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=LibraryContainerLocator.from_string( - container_data["id"], - ), - ), - }, - update_receiver.call_args_list[0].kwargs, - ) def test_restore_unit(self): """ @@ -352,9 +249,6 @@ def test_restore_unit(self): # Delete the unit self._delete_container(container_data["id"]) - create_receiver = mock.Mock() - LIBRARY_CONTAINER_CREATED.connect(create_receiver) - # Restore container self._restore_container(container_data["id"]) new_container_data = self._get_container(container_data["id"]) @@ -372,20 +266,6 @@ def test_restore_unit(self): 'collections': [], } - self.assertDictContainsEntries(new_container_data, expected_data) - - assert create_receiver.call_count == 1 - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_CREATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=LibraryContainerLocator.from_string("lct:CL-TEST:containers:unit:u1"), - ), - }, - create_receiver.call_args_list[0].kwargs, - ) - def test_container_collections(self): # Create a library lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") @@ -444,12 +324,6 @@ def test_publish_container(self): # pylint: disable=too-many-statements c2_before = self._get_container(container2["id"]) assert c2_before["has_unpublished_changes"] - # Set up event receivers after the initial mock data setup is complete: - updated_container_receiver = mock.Mock() - updated_block_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(updated_container_receiver) - LIBRARY_BLOCK_UPDATED.connect(updated_block_receiver) - # Now publish only Container 1 self._publish_container(container1["id"]) @@ -476,27 +350,3 @@ def test_publish_container(self): # pylint: disable=too-many-statements assert c2_components_after[1]["id"] == html_block2["id"] assert c2_components_after[1]["has_unpublished_changes"] # unaffected assert c2_components_after[1]["published_by"] is None - - # Make sure that the right events were sent out. - # First, there should be one container updated event: - assert len(updated_container_receiver.call_args_list) == 1 - self.assertDictContainsSubset( - { - "signal": LIBRARY_CONTAINER_UPDATED, - "library_container": LibraryContainerData( - container_key=LibraryContainerLocator.from_string(container1["id"]), - ), - }, - updated_container_receiver.call_args_list[0].kwargs, - ) - - # Second, two XBlock updated events: - assert len(updated_block_receiver.call_args_list) == 2 - updated_block_ids = set( - call.kwargs["library_block"].usage_key for call in updated_block_receiver.call_args_list - ) - assert updated_block_ids == { - LibraryUsageLocatorV2.from_string(problem_block["id"]), - LibraryUsageLocatorV2.from_string(html_block["id"]), - } - assert all(call.kwargs["signal"] == LIBRARY_BLOCK_UPDATED for call in updated_block_receiver.call_args_list) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index e1b34ebfcbf8..e2fec3aee1ff 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -3,7 +3,7 @@ """ from datetime import datetime, timezone from unittest import skip -from unittest.mock import Mock, patch +from unittest.mock import patch import ddt from django.contrib.auth.models import Group @@ -11,16 +11,6 @@ from django.test.client import Client from freezegun import freeze_time from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 -from openedx_events.content_authoring.data import ContentLibraryData, LibraryBlockData -from openedx_events.content_authoring.signals import ( - CONTENT_LIBRARY_CREATED, - CONTENT_LIBRARY_DELETED, - CONTENT_LIBRARY_UPDATED, - LIBRARY_BLOCK_CREATED, - LIBRARY_BLOCK_DELETED, - LIBRARY_BLOCK_UPDATED -) -from openedx_events.tests.utils import OpenEdxEventsTestMixin from organizations.models import Organization from rest_framework.test import APITestCase @@ -31,7 +21,7 @@ URL_BLOCK_METADATA_URL, URL_BLOCK_RENDER_VIEW, URL_BLOCK_XBLOCK_HANDLER, - ContentLibrariesRestApiTest + ContentLibrariesRestApiTest, ) from openedx.core.djangoapps.xblock import api as xblock_api from openedx.core.djangolib.testing.utils import skip_unless_cms @@ -39,7 +29,7 @@ @skip_unless_cms @ddt.ddt -class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class ContentLibrariesTestCase(ContentLibrariesRestApiTest): """ General tests for Learning-Core-based Content Libraries @@ -62,26 +52,6 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix library slug and bundle UUID does not because it's assumed to be immutable and cached forever. """ - ENABLED_OPENEDX_EVENTS = [ - CONTENT_LIBRARY_CREATED.event_type, - CONTENT_LIBRARY_DELETED.event_type, - CONTENT_LIBRARY_UPDATED.event_type, - LIBRARY_BLOCK_CREATED.event_type, - LIBRARY_BLOCK_DELETED.event_type, - LIBRARY_BLOCK_UPDATED.event_type, - ] - - @classmethod - def setUpClass(cls): - """ - Set up class method for the Test class. - - TODO: It's unclear why we need to call start_events_isolation ourselves rather than relying on - OpenEdxEventsTestMixin.setUpClass to handle it. It fails it we don't, and many other test cases do it, - so we're following a pattern here. But that pattern doesn't really make sense. - """ - super().setUpClass() - cls.start_events_isolation() def test_library_crud(self): """ @@ -357,6 +327,7 @@ def test_library_blocks(self): # pylint: disable=too-many-statements block_data["has_unpublished_changes"] = False block_data["last_published"] = publish_date.isoformat().replace('+00:00', 'Z') block_data["published_by"] = "Bob" + block_data["published_display_name"] = "Blank Problem" self.assertDictContainsEntries(self._get_library_block(block_id), block_data) assert self._get_library_blocks(lib_id)['results'] == [block_data] @@ -470,6 +441,7 @@ def test_library_blocks_studio_view(self): block_data["has_unpublished_changes"] = False block_data["last_published"] = publish_date.isoformat().replace('+00:00', 'Z') block_data["published_by"] = "Bob" + block_data["published_display_name"] = "Text" self.assertDictContainsEntries(self._get_library_block(block_id), block_data) assert self._get_library_blocks(lib_id)['results'] == [block_data] @@ -792,294 +764,6 @@ def test_library_blocks_limit(self): # Second block should throw error self._add_block_to_library(lib_id, "problem", "problem1", expect_response=400) - def test_content_library_create_event(self): - """ - Check that CONTENT_LIBRARY_CREATED event is sent when a content library is created. - """ - event_receiver = Mock() - CONTENT_LIBRARY_CREATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_event_create", - title="Event Test Library", - description="Testing event in library" - ) - library_key = LibraryLocatorV2.from_string(lib['id']) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": CONTENT_LIBRARY_CREATED, - "sender": None, - "content_library": ContentLibraryData( - library_key=library_key, - update_blocks=False, - ), - }, - event_receiver.call_args.kwargs - ) - - def test_content_library_update_event(self): - """ - Check that CONTENT_LIBRARY_UPDATED event is sent when a content library is updated. - """ - event_receiver = Mock() - CONTENT_LIBRARY_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_event_update", - title="Event Test Library", - description="Testing event in library" - ) - - lib2 = self._update_library(lib["id"], title="New Title") - library_key = LibraryLocatorV2.from_string(lib2['id']) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": CONTENT_LIBRARY_UPDATED, - "sender": None, - "content_library": ContentLibraryData( - library_key=library_key, - update_blocks=False, - ), - }, - event_receiver.call_args.kwargs - ) - - def test_content_library_delete_event(self): - """ - Check that CONTENT_LIBRARY_DELETED event is sent when a content library is deleted. - """ - event_receiver = Mock() - CONTENT_LIBRARY_DELETED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_event_delete", - title="Event Test Library", - description="Testing event in library" - ) - library_key = LibraryLocatorV2.from_string(lib['id']) - - self._delete_library(lib["id"]) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": CONTENT_LIBRARY_DELETED, - "sender": None, - "content_library": ContentLibraryData( - library_key=library_key, - update_blocks=False, - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_create_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when a library block is created. - """ - event_receiver = Mock() - LIBRARY_BLOCK_CREATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_create", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - self._add_block_to_library(lib_id, "problem", "problem1") - - library_key = LibraryLocatorV2.from_string(lib_id) - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="problem", - usage_id="problem1" - ) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_CREATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_olx_update_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when the OLX source is updated. - """ - event_receiver = Mock() - LIBRARY_BLOCK_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_olx_update", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "problem", "problem1") - block_id = block["id"] - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="problem", - usage_id="problem1" - ) - - new_olx = """ - - -

    This is a normal capa problem with unicode 🔥. It has "maximum attempts" set to **5**.

    - - - XBlock metadata only - XBlock data/metadata and associated static asset files - Static asset files for XBlocks and courseware - XModule metadata only - -
    -
    - """.strip() - - self._set_library_block_olx(block_id, new_olx) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_UPDATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_add_asset_update_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when a static asset is - uploaded associated with the XBlock. - """ - event_receiver = Mock() - LIBRARY_BLOCK_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_add_asset_update", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "html", "h1") - block_id = block["id"] - self._set_library_block_asset(block_id, "static/test.txt", b"data") - - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="html", - usage_id="h1" - ) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_UPDATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_del_asset_update_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when a static asset is - removed from XBlock. - """ - event_receiver = Mock() - LIBRARY_BLOCK_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_del_asset_update", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "html", "h321") - block_id = block["id"] - self._set_library_block_asset(block_id, "static/test.txt", b"data") - - self._delete_library_block_asset(block_id, 'static/text.txt') - - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="html", - usage_id="h321" - ) - - event_receiver.assert_called() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_UPDATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_delete_event(self): - """ - Check that LIBRARY_BLOCK_DELETED event is sent when a content library is deleted. - """ - event_receiver = Mock() - LIBRARY_BLOCK_DELETED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_delete", - title="Event Test Library", - description="Testing event in library" - ) - - lib_id = lib["id"] - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "problem", "problem1") - block_id = block['id'] - - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="problem", - usage_id="problem1" - ) - - self._delete_library_block(block_id) - - event_receiver.assert_called() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_DELETED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - def test_library_paste_xblock(self): """ Check the a new block is created in the library after pasting from clipboard. diff --git a/openedx/core/djangoapps/content_libraries/tests/test_course_to_library.py b/openedx/core/djangoapps/content_libraries/tests/test_course_to_library.py index e6b379d298a0..8f45fdd8cc9d 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_course_to_library.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_course_to_library.py @@ -3,8 +3,6 @@ """ import ddt from opaque_keys.edx.locator import LibraryContainerLocator -from openedx_events.content_authoring import signals -from openedx_events.tests.utils import OpenEdxEventsTestMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import ToyCourseFactory @@ -15,15 +13,10 @@ @skip_unless_cms @ddt.ddt -class CourseToLibraryTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest, ModuleStoreTestCase): +class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase): """ Tests that involve copying content from courses to libraries. """ - ENABLED_OPENEDX_EVENTS = [ - signals.LIBRARY_CONTAINER_CREATED.event_type, - signals.LIBRARY_CONTAINER_DELETED.event_type, - signals.LIBRARY_CONTAINER_UPDATED.event_type, - ] def test_library_paste_unit_from_course(self): """ diff --git a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py index e9909b7d6063..41abeed82986 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py @@ -8,7 +8,6 @@ import ddt from django.core.exceptions import ValidationError from django.test.utils import override_settings -from openedx_events.tests.utils import OpenEdxEventsTestMixin import pytest from xblock.core import XBlock @@ -22,7 +21,7 @@ @skip_unless_cms @ddt.ddt @override_settings(CORS_ORIGIN_WHITELIST=[]) # For some reason, this setting isn't defined in our test environment? -class LibrariesEmbedViewTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class LibrariesEmbedViewTestCase(ContentLibrariesRestApiTest): """ Tests for embed_view and interacting with draft/published/past versions of Learning-Core-based XBlocks (in Content Libraries). diff --git a/openedx/core/djangoapps/content_libraries/tests/test_events.py b/openedx/core/djangoapps/content_libraries/tests/test_events.py new file mode 100644 index 000000000000..e0e3e7392765 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/tests/test_events.py @@ -0,0 +1,548 @@ +""" +Tests for Learning-Core-based Content Libraries +""" +from opaque_keys.edx.locator import ( + LibraryCollectionLocator, + LibraryContainerLocator, + LibraryLocatorV2, + LibraryUsageLocatorV2, +) +from openedx_events.content_authoring.signals import ( + ContentLibraryData, + LibraryBlockData, + LibraryCollectionData, + LibraryContainerData, + CONTENT_LIBRARY_CREATED, + CONTENT_LIBRARY_DELETED, + CONTENT_LIBRARY_UPDATED, + LIBRARY_BLOCK_CREATED, + LIBRARY_BLOCK_DELETED, + LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, + LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, +) + +from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest +from openedx.core.djangolib.testing.utils import skip_unless_cms + + +@skip_unless_cms +class ContentLibrariesEventsTestCase(ContentLibrariesRestApiTest): + """ + Event tests for Learning-Core-based Content Libraries + + These tests use the REST API, which in turn relies on the Python API. + """ + # Note: we assume all events are already enabled, as they should be. We do + # NOT use OpenEdxEventsTestMixin, because it disables any events that you + # don't explicitly enable and does so in a way that interferes with other + # test cases, causing flakiness and failures in *other* test modules. + ALL_EVENTS = [ + CONTENT_LIBRARY_CREATED, + CONTENT_LIBRARY_DELETED, + CONTENT_LIBRARY_UPDATED, + LIBRARY_BLOCK_CREATED, + LIBRARY_BLOCK_DELETED, + LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, + LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, + ] + + def setUp(self) -> None: + super().setUp() + + # Create some useful data: + self.lib1 = self._create_library( + slug="test_lib_1", + title="Library 1", + description="First Library for testing", + ) + self.lib1_key = LibraryLocatorV2.from_string(self.lib1['id']) + + # From now on, every time an event is emitted, add it to this set: + self.new_events: list[dict] = [] + + def event_receiver(**kwargs) -> None: + self.new_events.append(kwargs) + + for e in self.ALL_EVENTS: + e.connect(event_receiver) + + def disconnect_all() -> None: + for e in self.ALL_EVENTS: + e.disconnect(event_receiver) + + self.addCleanup(disconnect_all) + + def clear_events(self) -> None: + """ Clear the log of events that we've seen so far. """ + self.new_events.clear() + + def expect_new_events(self, *expected_events: dict) -> None: + """ + assert the the specified events have been emitted since the last call to + this function. + """ + # We assume the events may not be in order. Assuming a specific order can lead to flaky tests. + for expected in expected_events: + found = False + for i, actual in enumerate(self.new_events): + if expected.items() <= actual.items(): + self.new_events.pop(i) + found = True + break + if not found: + raise AssertionError(f"Event {expected} not found among actual events: {self.new_events}") + if len(self.new_events) > 0: + raise AssertionError(f"Events were emitted but not expected: {self.new_events}") + self.clear_events() + + ############################## Libraries ################################## + + def test_content_library_crud_events(self) -> None: + """ + Check that CONTENT_LIBRARY_CREATED event is sent when a content library is created, updated, and deleted + """ + # Setup: none + # Action - create a library + new_lib = self._create_library( + slug="new_lib", + title="New Testing Library", + description="New Library for testing", + ) + lib_key = LibraryLocatorV2.from_string(new_lib['id']) + + # Expect a CREATED event: + self.expect_new_events({ + "signal": CONTENT_LIBRARY_CREATED, + "content_library": ContentLibraryData(library_key=lib_key), + }) + + # Action - change the library name: + self._update_library(lib_key=str(lib_key), title="New title") + # Expect an UPDATED event: + self.expect_new_events({ + "signal": CONTENT_LIBRARY_UPDATED, + "content_library": ContentLibraryData(library_key=lib_key), + }) + + # Action - delete the library: + self._delete_library(str(lib_key)) + # Expect a DELETED event: + self.expect_new_events({ + "signal": CONTENT_LIBRARY_DELETED, + "content_library": ContentLibraryData(library_key=lib_key), + }) + + # Should deleting a library send out _DELETED events for all the items in the library too? + + ############################## Components (XBlocks) ################################## + + def test_library_block_create_event(self) -> None: + """ + Check that LIBRARY_BLOCK_CREATED event is sent when a library block is created. + """ + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem1") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + + self.expect_new_events({ + "signal": LIBRARY_BLOCK_CREATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + def test_library_block_update_and_publish_events(self) -> None: + """ + Check that appropriate events are emitted when an existing block is updated. + """ + # This block should be ignored: + self._add_block_to_library(self.lib1_key, "problem", "problem1") + # This block will be used in the tests: + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem2") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + # Clear events from creating the blocks: + self.clear_events() + + # Now update the block's OLX: + new_olx = """ + + ... + + """.strip() + self._set_library_block_olx(usage_key, new_olx) + self.expect_new_events({ + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Now add a static asset file to the block: + self._set_library_block_asset(usage_key, "static/test.txt", b"data") + self.expect_new_events({ + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Then delete the static asset: + self._delete_library_block_asset(usage_key, 'static/text.txt') + self.expect_new_events({ + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Then publish the block: + self._publish_library_block(usage_key) + self.expect_new_events({ + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + def test_revert_delete(self) -> None: + """ + Test that when a block is deleted and then the delete is reverted, a + _CREATED event is sent. + """ + # This block should be ignored: + self._add_block_to_library(self.lib1_key, "problem", "problem1") + # This block will be used in the tests: + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem2") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + # Publish changes + self._commit_library_changes(self.lib1_key) + # Clear events from creating the blocks: + self.clear_events() + + # Delete the block: + self._delete_library_block(usage_key) + # That should emit a _DELETED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_DELETED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Revert the change: + self._revert_library_changes(self.lib1_key) + # That should result in a _CREATED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_CREATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + def test_revert_create(self) -> None: + """ + Test that when a block is created and then the changes are reverted, a + _DELETED event is sent. + """ + # Publish any changes from setUp() + self._commit_library_changes(self.lib1_key) + # Clear events: + self.clear_events() + + # Create the block: + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem2") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + # That should result in a _CREATED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_CREATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Revert the change: + self._revert_library_changes(self.lib1_key) + # That should result in a _DELETED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_DELETED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + ############################## Containers ################################## + + def test_unit_crud(self) -> None: + """ + Test Create, Read, Update, and Delete of a Unit + """ + # Create a unit: + container_data = self._create_container(self.lib1_key, "unit", slug="u1", display_name="Test Unit") + container_key = LibraryContainerLocator.from_string(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + # Update the unit: + self._update_container(container_key, display_name="Unit ABC") + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key), + }) + + # Delete the unit + self._delete_container(container_key) + self._get_container(container_key, expect_response=404) + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_DELETED, + "library_container": LibraryContainerData(container_key), + }) + + def test_publish_all_lib_changes(self) -> None: + """ + Test the events that get emitted when we publish all changes in the library + """ + # Create two containers and add some components + # -> container 1: problem_block, html_block + # -> container 2: html_block, html_block2 + container1 = self._create_container(self.lib1_key, "unit", display_name="Alpha Unit", slug=None) + container2 = self._create_container(self.lib1_key, "unit", display_name="Bravo Unit", slug=None) + problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False) + html_block = self._add_block_to_library(self.lib1_key, "html", "Html1", can_stand_alone=False) + html_block2 = self._add_block_to_library(self.lib1_key, "html", "Html2", can_stand_alone=False) + self._add_container_components(container1["id"], children_ids=[problem_block["id"], html_block["id"]]) + self._add_container_components(container2["id"], children_ids=[html_block["id"], html_block2["id"]]) + + # Now publish only Container 2 (which will auto-publish both HTML blocks since they're children) + self._publish_container(container2["id"]) + # Container 2 is published, container 1 and its contents is unpublished: + assert self._get_container(container2["id"])["has_unpublished_changes"] is False + assert self._get_container(container1["id"])["has_unpublished_changes"] + assert self._get_library_block(problem_block["id"])["has_unpublished_changes"] + assert self._get_library_block(html_block["id"])["has_unpublished_changes"] is False # in containers 1+2 + + # clear event log up to this point + self.clear_events() + + # Now publish ALL remaining changes in the library: + self._commit_library_changes(self.lib1_key) + # Container 1 is now published: + assert self._get_container(container1["id"])["has_unpublished_changes"] is False + # And publish events were emitted: + self.expect_new_events( + { # An event for container 1 being published: + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container1["id"]), + ), + }, + { # An event for the problem block in container 1: + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(problem_block["id"]), + ), + }, + # The HTML block in container 1 is not part of this publish event group, because it was + # already published when we published container 2 + ) + + def test_publish_child_block(self) -> None: + """ + Test the events that get emitted when we publish changes to a child of a container + """ + # Create a container and a block + container1 = self._create_container(self.lib1_key, "unit", display_name="Alpha Unit", slug=None) + problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False) + self._add_container_components(container1["id"], children_ids=[problem_block["id"]]) + # Publish all changes + self._commit_library_changes(self.lib1_key) + assert self._get_container(container1["id"])["has_unpublished_changes"] is False + + # Change only the block, not the container: + self._set_library_block_olx(problem_block["id"], "UPDATED") + # Since we modified the block, the container now contains changes (technically it is unchanged and its + # version is the same, but it *contains* unpublished changes) + assert self._get_library_block(problem_block["id"])["has_unpublished_changes"] + assert self._get_container(container1["id"])["has_unpublished_changes"] + # clear event log up to this point + self.clear_events() + + # Now publish ALL remaining changes in the library - should only affect the problem block + self._commit_library_changes(self.lib1_key) + # The container no longer contains unpublished changes: + assert self._get_container(container1["id"])["has_unpublished_changes"] is False + # And publish events were emitted: + self.expect_new_events( + { # An event for container 1 being affected indirectly by the child being published: + # TODO: should this be a CONTAINER_CHILD_PUBLISHED event? + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container1["id"]), + ), + }, + { # An event for the problem block: + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(problem_block["id"]), + ), + }, + ) + + def test_publish_container(self) -> None: + """ + Test the events that get emitted when we publish the changes to a specific container + """ + # Create two containers and add some components + container1 = self._create_container(self.lib1_key, "unit", display_name="Alpha Unit", slug=None) + container2 = self._create_container(self.lib1_key, "unit", display_name="Bravo Unit", slug=None) + problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False) + html_block = self._add_block_to_library(self.lib1_key, "html", "Html1", can_stand_alone=False) + html_block2 = self._add_block_to_library(self.lib1_key, "html", "Html2", can_stand_alone=False) + self._add_container_components(container1["id"], children_ids=[problem_block["id"], html_block["id"]]) + self._add_container_components(container2["id"], children_ids=[html_block["id"], html_block2["id"]]) + # At first everything is unpublished: + c1_before = self._get_container(container1["id"]) + assert c1_before["has_unpublished_changes"] + c2_before = self._get_container(container2["id"]) + assert c2_before["has_unpublished_changes"] + + # clear event log after the initial mock data setup is complete: + self.clear_events() + + # Now publish only Container 1 + self._publish_container(container1["id"]) + + # Now it is published: + c1_after = self._get_container(container1["id"]) + assert c1_after["has_unpublished_changes"] is False + # And publish events were emitted: + self.expect_new_events( + { # An event for container 1 being published: + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container1["id"]), + ), + }, + { # An event for the problem block in container 1: + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(problem_block["id"]), + ), + }, + { # An event for the html block in container 1 (and container 2): + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(html_block["id"]), + ), + }, + { # Not 100% sure we want this, but a PUBLISHED event is emitted for container 2 + # because one of its children's published versions has changed, so whether or + # not it contains unpublished changes may have changed and the search index + # may need to be updated. It is not actually published though. + # TODO: should this be a CONTAINER_CHILD_PUBLISHED event? + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container2["id"]), + ), + }, + ) + + # note that container 2 is still unpublished + c2_after = self._get_container(container2["id"]) + assert c2_after["has_unpublished_changes"] + + def test_restore_unit(self) -> None: + """ + Test restoring a deleted unit via the "restore" API. + """ + # Create a unit: + container_data = self._create_container(self.lib1_key, "unit", slug="u1", display_name="Test Unit") + container_key = LibraryContainerLocator.from_string(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + # Delete the unit + self._delete_container(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_DELETED, + "library_container": LibraryContainerData(container_key), + }) + + # Restore the unit + self._restore_container(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + def test_restore_unit_via_revert(self) -> None: + """ + Test restoring a deleted unit by reverting changes. + """ + # Publish the existing setup and clear events + self._commit_library_changes(self.lib1_key) + self.clear_events() + + # Create a unit: + container_data = self._create_container(self.lib1_key, "unit", slug="u1", display_name="Test Unit") + container_key = LibraryContainerLocator.from_string(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + # Publish changes + self._publish_container(container_key) + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData(container_key), + }) + + # Delete the unit + self._delete_container(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_DELETED, + "library_container": LibraryContainerData(container_key), + }) + + # Revert changes, which will re-create the unit: + self._revert_library_changes(self.lib1_key) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + ############################## Collections ################################## + + def test_collection_crud(self) -> None: + """ Test basic create, update, and delete events for collections """ + collection = self._create_collection(self.lib1_key, "Test Collection") + # To fix? The response from _create_collection should have the opaque key as the "id" field, not an integer. + collection_key = LibraryCollectionLocator(lib_key=self.lib1_key, collection_id=collection["key"]) + self.expect_new_events({ + "signal": LIBRARY_COLLECTION_CREATED, + "library_collection": LibraryCollectionData(collection_key), + }) + + # Update the collection: + self._update_collection(collection_key, description="Updated description") + self.expect_new_events({ + "signal": LIBRARY_COLLECTION_UPDATED, + "library_collection": LibraryCollectionData(collection_key), + }) + + # Soft delete the collection. NOTE: at the moment, it's only possible to "soft delete" collections via + # the REST API, which sends an UPDATED event because the collection is now "disabled" but not deleted. + self._soft_delete_collection(collection_key) + self.expect_new_events({ + "signal": LIBRARY_COLLECTION_UPDATED, # UPDATED not DELETED. If we do a hard delete, it should be DELETED. + "library_collection": LibraryCollectionData(collection_key), + }) + + # TODO: move more of the event-related collection tests from test_api.py to here, and convert them to use REST APIs diff --git a/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py b/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py index ad7ea54d8dfb..20d0b38f0b7c 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py @@ -2,7 +2,6 @@ Tests that several XBlock APIs support versioning """ from django.test.utils import override_settings -from openedx_events.tests.utils import OpenEdxEventsTestMixin from xblock.core import XBlock from openedx.core.djangoapps.content_libraries.tests.base import ( @@ -14,7 +13,7 @@ @skip_unless_cms @override_settings(CORS_ORIGIN_WHITELIST=[]) # For some reason, this setting isn't defined in our test environment? -class VersionedXBlockApisTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class VersionedXBlockApisTestCase(ContentLibrariesRestApiTest): """ Tests for three APIs implemented by djangoapps.xblock, and used by content libraries. These tests focus on versioning. diff --git a/openedx/core/djangoapps/contentserver/test/test_contentserver.py b/openedx/core/djangoapps/contentserver/test/test_contentserver.py index 4c0180c402e9..df29b64d2781 100644 --- a/openedx/core/djangoapps/contentserver/test/test_contentserver.py +++ b/openedx/core/djangoapps/contentserver/test/test_contentserver.py @@ -102,6 +102,11 @@ def setUpClass(cls): cls.url_unlocked_versioned_old_style = get_old_style_versioned_asset_url(cls.url_unlocked) cls.length_unlocked = cls.contentstore.get_attr(cls.unlocked_asset, 'length') + # Special case: python_lib.zip + cls.pylib_asset = cls.course_key.make_asset_key('asset', 'python_lib.zip') + cls.url_pylib = '/' + str(cls.pylib_asset) + cls.contentstore.set_attr(cls.pylib_asset, 'locked', False) + def setUp(self): """ Create user and login. @@ -208,6 +213,83 @@ def test_locked_asset_staff(self): resp = self.client.get(self.url_locked) assert resp.status_code == 200 + def test_python_lib_zip_staff(self): + """ + Test that staff can download python_lib.zip. + """ + self.client.login(username=self.staff_usr, password=self.TEST_PASSWORD) + resp = self.client.get(self.url_pylib) + assert resp.status_code == 200 + assert resp['Cache-Control'] == 'private, no-cache, no-store' + + def test_python_lib_zip_not_staff(self): + """ + Test that python_lib.zip cannot be downloaded by non-staff by default. + """ + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) + resp = self.client.get(self.url_pylib) + assert resp.status_code == 403 + # We should be sending a no-cache header, but the contentserver + # currently doesn't set caching headers for "unauthorized" responses. So + # this test allows either in order to make the transition easier if we + # fix that. + assert 'Cache-Control' not in resp or resp['Cache-Control'] == 'private, no-cache, no-store' + + @patch( + 'openedx.core.djangoapps.contentserver.views.COURSE_CODE_LIBRARY_DOWNLOAD_ALLOWED.is_enabled', + return_value=True, + ) + def test_python_lib_zip_not_staff_but_course_allows_it(self, mock_download_allowed_flag): + """ + Test that python_lib.zip can be downloaded by non-staff when flag enabled. + """ + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) + resp = self.client.get(self.url_pylib) + assert resp.status_code == 200 + assert resp['Cache-Control'] == 'private, no-cache, no-store' + + mock_download_allowed_flag.assert_called_once_with(self.course_key) + + @patch( + 'openedx.core.djangoapps.contentserver.views.COURSE_CODE_LIBRARY_DOWNLOAD_ALLOWED.is_enabled', + return_value=True, + ) + @patch( + 'openedx.core.djangoapps.contentserver.views.is_content_locked', + return_value=True, + ) + def test_python_lib_zip_can_be_locked(self, mock_is_locked, mock_download_allowed_flag): + """ + Even when python_lib.zip download is broadly allowed, it can be locked. + """ + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) + resp = self.client.get(self.url_pylib) + assert resp.status_code == 403 + assert 'Cache-Control' not in resp or resp['Cache-Control'] == 'private, no-cache, no-store' + + assert mock_is_locked.call_count == 2 # for auth check, then caching check + mock_download_allowed_flag.assert_called_once_with(self.course_key) + + @ddt.data(True, False) + def test_python_lib_zip_uses_studio_read_check(self, allow): + """ + Specifically check that python_lib.zip is gated on studio read access. + + Ideally this test would actually check access for a course team member + who is *not* site staff/superuser, but that would require more + complicated setup. + """ + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) + with patch('openedx.core.djangoapps.contentserver.views.has_studio_read_access', return_value=allow): + resp = self.client.get(self.url_pylib) + + if allow: + assert resp.status_code == 200 + assert resp['Cache-Control'] == 'private, no-cache, no-store' + else: + assert resp.status_code == 403 + assert 'Cache-Control' not in resp or resp['Cache-Control'] == 'private, no-cache, no-store' + def test_range_request_full_file(self): """ Test that a range request from byte 0 to last, diff --git a/openedx/core/djangoapps/contentserver/views.py b/openedx/core/djangoapps/contentserver/views.py index 3a267f085222..cbdb4124fef7 100644 --- a/openedx/core/djangoapps/contentserver/views.py +++ b/openedx/core/djangoapps/contentserver/views.py @@ -21,13 +21,16 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import AssetLocator +from common.djangoapps.student.auth import has_studio_read_access from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.header_control import force_header_for_response +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag from xmodule.assetstore.assetmgr import AssetManager from xmodule.contentstore.content import XASSET_LOCATION_TAG, StaticContent from xmodule.exceptions import NotFoundError from xmodule.modulestore import InvalidLocationError from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.util.sandboxing import course_code_library_asset_name from .caching import get_cached_content, set_cached_content from .models import CdnUserAgentsConfig, CourseAssetCacheTtlConfig @@ -197,17 +200,21 @@ def process_request(request): # middleware we have in place, there's no easy way to use the built-in Django # utilities and properly sanitize and modify a response to ensure that it is as # cacheable as possible, which is why we do it ourselves. - set_caching_headers(content, response) + set_caching_headers(content, loc, response) return response -def set_caching_headers(content, response): +def set_caching_headers(content, location, response): """ - Sets caching headers based on whether or not the asset is locked. + Sets caching headers based on whether or not the asset is restricted. """ - is_locked = getattr(content, "locked", False) + is_pylib = location.path == course_code_library_asset_name() + + # All classes of asset that have any kind of access control should be marked + # as non-cacheable. + is_restricted = is_locked or is_pylib # We want to signal to the end user's browser, and to any intermediate proxies/caches, # whether or not this asset is cacheable. If we have a TTL configured, we inform the @@ -215,12 +222,12 @@ def set_caching_headers(content, response): # assets should be restricted to enrolled students, we simply send headers that # indicate there should be no caching whatsoever. cache_ttl = CourseAssetCacheTtlConfig.get_cache_ttl() - if cache_ttl > 0 and not is_locked: + if cache_ttl > 0 and not is_restricted: set_custom_attribute('contentserver.cacheable', True) response['Expires'] = get_expiration_value(datetime.datetime.utcnow(), cache_ttl) response['Cache-Control'] = "public, max-age={ttl}, s-maxage={ttl}".format(ttl=cache_ttl) - elif is_locked: + elif is_restricted: set_custom_attribute('contentserver.cacheable', False) response['Cache-Control'] = "private, no-cache, no-store" @@ -264,10 +271,43 @@ def is_content_locked(content): return bool(getattr(content, "locked", False)) +# .. toggle_name: course_assets.allow_download_code_library +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Whether to allow learners to download the course code library +# that is used for custom Python-graded problem blocks. (This is conventionally +# ``python_lib.zip``, but configurable with Django setting ``PYTHON_LIB_FILENAME``). +# This file may contain custom grading code or problem answers that should not be +# revealed to learners. +# .. toggle_warning: This flag is only intended as a temporary override for use +# in rollout, to be removed before Ulmo. Courses that rely on learners being able +# to download the code library should find an alternative workflow, or the toggle +# should be re-documented as permanent. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2025-05-01 +# .. toggle_target_removal_date: 2025-10-01 +COURSE_CODE_LIBRARY_DOWNLOAD_ALLOWED = CourseWaffleFlag( + 'course_assets.allow_download_code_library', module_name=__name__, +) + + def is_user_authorized(request, content, location): """ Determines whether or not the user for this request is authorized to view the given asset. + + Any asset classes that have restrictions placed on them should also + be marked as no-cache in `set_caching_headers`. """ + # Special-case python_lib.zip, since it often contains grading code that + # shouldn't be revealed to learners. + if location.path == course_code_library_asset_name(): + if has_studio_read_access(request.user, location.course_key) or \ + COURSE_CODE_LIBRARY_DOWNLOAD_ALLOWED.is_enabled(location.course_key): + # Fall through to other access checks + pass + else: + return False + if not is_content_locked(content): return True diff --git a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py index 0f6dd85863ba..910eff2b0d01 100644 --- a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py +++ b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py @@ -44,6 +44,12 @@ def setUpClass(cls): super().setUpClass() cls.start_events_isolation() + @classmethod + def tearDownClass(cls): + """ Don't let our event isolation affect other test cases """ + super().tearDownClass() + cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated. + def setUp(self): super().setUp() self.course_key = CourseLocator("dummy", "dummy", "dummy") diff --git a/openedx/core/djangoapps/course_groups/tests/test_events.py b/openedx/core/djangoapps/course_groups/tests/test_events.py index 11ec0e365279..616a7bb3f156 100644 --- a/openedx/core/djangoapps/course_groups/tests/test_events.py +++ b/openedx/core/djangoapps/course_groups/tests/test_events.py @@ -46,6 +46,12 @@ def setUpClass(cls): super().setUpClass() cls.start_events_isolation() + @classmethod + def tearDownClass(cls): + """ Don't let our event isolation affect other test cases """ + super().tearDownClass() + cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated. + def setUp(self): # pylint: disable=arguments-differ super().setUp() self.course = CourseOverviewFactory() diff --git a/openedx/core/djangoapps/models/course_details.py b/openedx/core/djangoapps/models/course_details.py index c00d7d0b8816..dd60852cc902 100644 --- a/openedx/core/djangoapps/models/course_details.py +++ b/openedx/core/djangoapps/models/course_details.py @@ -27,6 +27,7 @@ 'short_description', 'overview', 'effort', + # 'complexity', 'entrance_exam_enabled', 'entrance_exam_id', 'entrance_exam_minimum_score_pct', @@ -193,6 +194,7 @@ def update_about_video(cls, course, video_id, user_id): @classmethod def update_from_json(cls, course_key, jsondict, user): # pylint: disable=too-many-statements + # print(">>> PAYLOAD:", jsondict) """ Decode the json into CourseDetails and save any changed attrs to the db """ @@ -309,7 +311,9 @@ def update_from_json(cls, course_key, jsondict, user): # pylint: disable=too-ma # the fields actually changed to make faster, could compare # against db or could have client send over a list of which # fields changed. + # print(">>> ATTRIBUTES:", ABOUT_ATTRIBUTES) for attribute in ABOUT_ATTRIBUTES: + if attribute in jsondict: cls.update_about_item(block, attribute, jsondict[attribute], user.id) diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py index 79535dbc21a2..cfc9791ae98a 100644 --- a/openedx/core/djangoapps/notifications/email/utils.py +++ b/openedx/core/djangoapps/notifications/email/utils.py @@ -94,12 +94,13 @@ def create_email_template_context(username): 'channel': 'email', 'value': False } + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') return { "platform_name": settings.PLATFORM_NAME, "mailing_address": settings.CONTACT_MAILING_ADDRESS, "logo_url": get_logo_url_for_email(), "social_media": social_media_info, - "notification_settings_url": f"{settings.ACCOUNT_MICROFRONTEND_URL}/#notifications", + "notification_settings_url": f"{account_base_url}/#notifications", "unsubscribe_url": get_unsubscribe_link(username, patch) } diff --git a/openedx/core/djangoapps/user_api/accounts/__init__.py b/openedx/core/djangoapps/user_api/accounts/__init__.py index caf78ca54ae0..56a8614be638 100644 --- a/openedx/core/djangoapps/user_api/accounts/__init__.py +++ b/openedx/core/djangoapps/user_api/accounts/__init__.py @@ -93,7 +93,7 @@ # Translators: These messages are shown to users who do not enter information # into the required field or enter it incorrectly. -REQUIRED_FIELD_NAME_MSG = _("Enter your full name") +REQUIRED_FIELD_NAME_MSG = _("Enter your surname and name") REQUIRED_FIELD_FIRST_NAME_MSG = _("Enter your first name") REQUIRED_FIELD_LAST_NAME_MSG = _("Enter your last name") REQUIRED_FIELD_CONFIRM_EMAIL_MSG = _("The email addresses do not match") @@ -109,7 +109,7 @@ REQUIRED_FIELD_LEVEL_OF_EDUCATION_MSG = _("Select the highest level of education you have completed") REQUIRED_FIELD_YEAR_OF_BIRTH_MSG = _("Select your year of birth") REQUIRED_FIELD_GENDER_MSG = _("Select your gender") -REQUIRED_FIELD_MAILING_ADDRESS_MSG = _("Enter your mailing address") +REQUIRED_FIELD_MAILING_ADDRESS_MSG = _("Enter your educational institution") # HIBP Strings AUTHN_LOGIN_BLOCK_HIBP_POLICY_MSG = _( diff --git a/openedx/core/djangoapps/user_api/legacy_urls.py b/openedx/core/djangoapps/user_api/legacy_urls.py index ad02f7f19ce8..3c8da9bd830a 100644 --- a/openedx/core/djangoapps/user_api/legacy_urls.py +++ b/openedx/core/djangoapps/user_api/legacy_urls.py @@ -1,7 +1,9 @@ """ Defines the URL routes for this app. """ +from django.conf import settings from django.urls import path, re_path, include +from django.views.generic import RedirectView from rest_framework import routers from . import views as user_api_views @@ -12,6 +14,10 @@ USER_API_ROUTER.register(r'user_prefs', user_api_views.UserPreferenceViewSet) urlpatterns = [ + # This redirect is needed for backward compatibility with the old URL structure for the authentication + # workflows using third-party authentication providers until the authentication workflows fully support + # the URL structure with MFEs. + re_path(r'^account(?:/settings)?/?$', RedirectView.as_view(url=settings.ACCOUNT_MICROFRONTEND_URL)), path('user_api/v1/', include(USER_API_ROUTER.urls)), re_path( fr'^user_api/v1/preferences/(?P{UserPreference.KEY_REGEX})/users/$', diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index d52493556a19..cef77e8708e3 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -24,6 +24,9 @@ ) from openedx.core.lib.api.permissions import ApiKeyHeaderPermission from openedx.core.lib.api.view_utils import require_post_params +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.debug import sensitive_post_parameters + class UserViewSet(viewsets.ReadOnlyModelViewSet): diff --git a/openedx/core/djangoapps/user_authn/api/form_fields.py b/openedx/core/djangoapps/user_authn/api/form_fields.py index ca001ba3f1b5..f21e494c113b 100644 --- a/openedx/core/djangoapps/user_authn/api/form_fields.py +++ b/openedx/core/djangoapps/user_authn/api/form_fields.py @@ -255,7 +255,7 @@ def add_mailing_address_field(is_field_required=False): """ # Translators: This label appears above a field # meant to hold the user's mailing address. - mailing_address_label = _("Mailing address") + mailing_address_label = _("Educational institution") return { 'name': 'mailing_address', diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index efee92e700b7..f92ddb5a6f8f 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -197,7 +197,7 @@ def __init__( "level_of_education": _("A level of education is required"), "gender": _("Your gender is required"), "year_of_birth": _("Your year of birth is required"), - "mailing_address": _("Your mailing address is required"), + "mailing_address": _("Your educational institution is required"), "goals": _("A description of your goals is required"), "city": _("A city is required"), "country": _("A country is required") @@ -557,7 +557,7 @@ def _add_name_field(self, form_desc, required=True): # Translators: These instructions appear on the registration form, immediately # below a field meant to hold the user's full name. - name_instructions = _("This name will be used on any certificates that you earn.") + name_instructions = _("Surname and Name") form_desc.add_field( "name", @@ -795,7 +795,7 @@ def _add_mailing_address_field(self, form_desc, required=True): """ # Translators: This label appears above a field on the registration form # meant to hold the user's mailing address. - mailing_address_label = _("Mailing address") + mailing_address_label = _("Educational institution") error_msg = accounts.REQUIRED_FIELD_MAILING_ADDRESS_MSG form_desc.add_field( diff --git a/openedx/core/lib/xblock_serializer/utils.py b/openedx/core/lib/xblock_serializer/utils.py index e78c900b1887..6f48eef391e7 100644 --- a/openedx/core/lib/xblock_serializer/utils.py +++ b/openedx/core/lib/xblock_serializer/utils.py @@ -2,11 +2,11 @@ Helper functions for XBlock serialization """ from __future__ import annotations + import logging import re from contextlib import contextmanager -from django.conf import settings from fs.memoryfs import MemoryFS from fs.wrapfs import WrapFS from opaque_keys import InvalidKeyError @@ -17,7 +17,7 @@ from xmodule.contentstore.content import StaticContent from xmodule.exceptions import NotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError -from xmodule.util.sandboxing import DEFAULT_PYTHON_LIB_FILENAME +from xmodule.util.sandboxing import course_code_library_asset_name from xmodule.xml_block import XmlMixin from .data import StaticFile @@ -105,7 +105,7 @@ def get_python_lib_zip_if_using(olx: str, course_id: CourseKey) -> StaticFile | using python_lib.zip """ if _has_python_script(olx): - python_lib_filename = getattr(settings, 'PYTHON_LIB_FILENAME', DEFAULT_PYTHON_LIB_FILENAME) + python_lib_filename = course_code_library_asset_name() asset_key = StaticContent.get_asset_key_from_path(course_id, python_lib_filename) # Now, it seems like this capa problem uses python_lib.zip - but does it exist in the course? if AssetManager.find(asset_key, throw_on_not_found=False): diff --git a/requirements/constraints.txt b/requirements/constraints.txt index eab8c113b034..ba888ac023ba 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -112,7 +112,7 @@ numpy<2.0.0 # Date: 2023-09-18 # pinning this version to avoid updates while the library is being developed # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269 -openedx-learning==0.25.0 +openedx-learning==0.26.0 # Date: 2023-11-29 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 3dfe90fd0695..470ed2ce8c73 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -801,7 +801,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.1.0 # via -r requirements/edx/kernel.in -openedx-events==10.0.0 +openedx-events==10.2.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -817,7 +817,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.2.0 # via -r requirements/edx/kernel.in -openedx-learning==0.25.0 +openedx-learning==0.26.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 0901a9179a41..283886b777e7 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1358,7 +1358,7 @@ openedx-django-wiki==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==10.0.0 +openedx-events==10.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1378,7 +1378,7 @@ openedx-forum==0.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-learning==0.25.0 +openedx-learning==0.26.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 54c3ba3b6519..3578bf31bb40 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -972,7 +972,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==10.0.0 +openedx-events==10.2.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -988,7 +988,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.2.0 # via -r requirements/edx/base.txt -openedx-learning==0.25.0 +openedx-learning==0.26.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index b0a7976ab37e..5e6f12192b8d 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1030,7 +1030,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==10.0.0 +openedx-events==10.2.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1046,7 +1046,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.2.0 # via -r requirements/edx/base.txt -openedx-learning==0.25.0 +openedx-learning==0.26.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/xmodule/capa/responsetypes.py b/xmodule/capa/responsetypes.py index 73378e7c0a2b..6155fe926ea7 100644 --- a/xmodule/capa/responsetypes.py +++ b/xmodule/capa/responsetypes.py @@ -2140,6 +2140,9 @@ def check_function(expect, ans, **kwargs): globals_dict, python_path=self.context['python_path'], extra_files=self.context['extra_files'], + limit_overrides_context=get_course_id_from_capa_block( + self.capa_block + ), slug=self.id, random_seed=self.context['seed'], unsafely=self.capa_system.can_execute_unsafe_code(), @@ -2291,6 +2294,9 @@ def execute_check_function(self, idset, submission): # lint-amnesty, pylint: di cache=self.capa_system.cache, python_path=self.context['python_path'], extra_files=self.context['extra_files'], + limit_overrides_context=get_course_id_from_capa_block( + self.capa_block + ), slug=self.id, random_seed=self.context['seed'], unsafely=self.capa_system.can_execute_unsafe_code(), @@ -3274,6 +3280,9 @@ def get_score(self, student_answers): cache=self.capa_system.cache, python_path=self.context['python_path'], extra_files=self.context['extra_files'], + limit_overrides_context=get_course_id_from_capa_block( + self.capa_block + ), slug=self.id, random_seed=self.context['seed'], unsafely=self.capa_system.can_execute_unsafe_code(), diff --git a/xmodule/capa/safe_exec/safe_exec.py b/xmodule/capa/safe_exec/safe_exec.py index de80fbb6e97d..cd7b55357901 100644 --- a/xmodule/capa/safe_exec/safe_exec.py +++ b/xmodule/capa/safe_exec/safe_exec.py @@ -272,7 +272,7 @@ def safe_exec( local_exc_unexpected = None if isinstance(exception, SafeExecException) else exception report_darklaunch_results( - slug=slug, + limit_overrides_context=limit_overrides_context, slug=slug, globals_local=globals_dict, emsg_local=emsg, unexpected_exc_local=local_exc_unexpected, globals_remote=darklaunch_globals, emsg_remote=remote_emsg, unexpected_exc_remote=remote_exception, ) @@ -291,6 +291,24 @@ def safe_exec( raise exception +def _compile_normalizers(normalizer_setting): + """ + Compile emsg normalizer search/replace pairs into regex. + + Raises exception on bad settings. + """ + compiled = [] + for pair in normalizer_setting: + search = re.compile(assert_type(pair['search'], str)) + replace = assert_type(pair['replace'], str) + + # Test the replacement string (might contain errors) + re.sub(search, replace, "example") + + compiled.append({'search': search, 'replace': replace}) + return compiled + + @lru_cache(maxsize=1) def emsg_normalizers(): """ @@ -299,37 +317,77 @@ def emsg_normalizers(): The output is like the setting value, except the 'search' patterns have been compiled. """ - default = [ + default_setting = [ { - 'search': r'/tmp/codejail-[0-9a-zA-Z]+', + # Character range should be at least as broad as what Python's `tempfile` uses. + 'search': r'/tmp/codejail-[0-9a-zA-Z_]+', 'replace': r'/tmp/codejail-', }, + + # These are useful for eliding differences in environments due to Python version: + + { + # Python 3.8 doesn't include the dir here, but Python 3.12 + # does. Normalize to the 3.8 version. + 'search': r'File "/tmp/codejail-/jailed_code"', + 'replace': r'File "jailed_code"' + }, + { + # Python version shows up in stack traces in the virtualenv paths + 'search': r'python3\.[0-9]+', + 'replace': r'python3.XX' + }, + { + # Line numbers in stack traces differ between Python versions + 'search': r', line [0-9]+, in ', + 'replace': r', line XXX, in ' + }, + { + # Some time after 3.8, Python started adding '^^^' indicators to stack traces + 'search': r'\\n\s*\^+\s*\\n', + 'replace': r'\\n' + }, + { + # Python3.8 had these stack trace elements but 3.12 does not + 'search': r'\\n File "[^"]+", line [0-9]+, in \\n', + 'replace': r'\\n' + }, ] + default_normalizers = _compile_normalizers(default_setting) + + # .. setting_name: CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS + # .. setting_default: [] + # .. setting_description: A list of patterns to search and replace in codejail error + # messages during comparison in codejail-service darklaunch. Each entry is a dict + # of 'search' (a regular expression string) and 'replace' (the replacement string). + # Deployers may also need to add a search/replace pair for the location of the sandbox + # virtualenv, or any other paths that show up in stack traces. + # .. setting_warning: Note that `replace' is a pattern, allowing for + # backreferences. Any backslashes in the replacement pattern that are not + # intended as backreferences should be escaped as `\\`. + # The default list suppresses differences due to the randomly-named sandboxes + # or to differences due to Python version. See setting + # ``CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE`` for information on how + # this setting interacts with the defaults. + custom_setting = getattr(settings, 'CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS', []) try: - # .. setting_name: CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS - # .. setting_default: (see description) - # .. setting_description: A list of patterns to search and replace in codejail error - # messages during comparison in codejail-service darklaunch. Each entry is a dict - # of 'search' (a regular expression string) and 'replace' (the replacement string). - # The default value suppresses differences matching '/tmp/codejail-[0-9a-zA-Z]+', - # the directory structure codejail uses for its random-named sandboxes. Deployers - # may also need to add a search/replace pair for the location of the sandbox - # virtualenv, or any other paths that show up in stack traces. - # .. setting_warning: Note that `replace' is a pattern, allowing for - # backreferences. Any backslashes in the replacement pattern that are not - # intended as backreferences should be escaped as `\\`. - setting = getattr(settings, 'CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS', default) - - compiled = [] - for pair in setting: - compiled.append({ - 'search': re.compile(assert_type(pair['search'], str)), - 'replace': assert_type(pair['replace'], str), - }) - return compiled + custom_normalizers = _compile_normalizers(custom_setting) except BaseException as e: + log.error("Could not load custom codejail darklaunch emsg normalizers") record_exception() - return [] + return default_normalizers + + # .. setting_name: CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE + # .. setting_default: 'append' + # .. setting_description: How to combine ``CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS`` + # with the defaults. If the value is 'replace', the defaults will be replaced + # with the specified patterns. If the value is 'append' (the default), the + # specified replacements will be run after the defaults. + combine = getattr(settings, 'CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE', 'append') + if combine == 'replace': + return custom_normalizers + else: # 'append', or unknown + return default_normalizers + custom_normalizers def normalize_error_message(emsg): @@ -346,14 +404,19 @@ def normalize_error_message(emsg): def report_darklaunch_results( - *, slug, + *, limit_overrides_context, slug, globals_local, emsg_local, unexpected_exc_local, globals_remote, emsg_remote, unexpected_exc_remote, ): """Send telemetry for results of darklaunch.""" can_compare_output = True - def report_arm(arm, globals_dict, emsg, unexpected_exception): + def report_arm(arm, emsg, unexpected_exception): + """ + Set custom attributes for each arm of the darklaunch experiment. + + `arm` should be 'local' or 'remote'. + """ nonlocal can_compare_output if unexpected_exception: # .. custom_attribute_name: codejail.darklaunch.status.{local,remote} @@ -372,25 +435,33 @@ def report_arm(arm, globals_dict, emsg, unexpected_exception): set_custom_attribute(f'codejail.darklaunch.status.{arm}', 'ok' if emsg is None else 'safe_error') set_custom_attribute(f'codejail.darklaunch.exception.{arm}', None) - # Logs include full globals and emsg - log.info( - f"Codejail darklaunch {arm} results for slug={slug}: globals={globals_dict!r}, " - f"emsg={emsg!r}, exception={unexpected_exception!r}" - ) - - report_arm('local', globals_local, emsg_local, unexpected_exc_local) - report_arm('remote', globals_remote, emsg_remote, unexpected_exc_remote) + report_arm('local', emsg_local, unexpected_exc_local) + report_arm('remote', emsg_remote, unexpected_exc_remote) # If the arms can't be compared (unexpected errors), stop early -- the rest # is about output comparison. if not can_compare_output: set_custom_attribute('codejail.darklaunch.globals_match', 'N/A') set_custom_attribute('codejail.darklaunch.emsg_match', 'N/A') + log.info( + "Codejail darklaunch had unexpected exception for " + f"course={limit_overrides_context!r}, slug={slug!r}:\n" + f"Local exception: {unexpected_exc_local!r}\n" + f"Remote exception: {unexpected_exc_remote!r}" + ) return globals_match = globals_local == globals_remote emsg_match = normalize_error_message(emsg_local) == normalize_error_message(emsg_remote) + if not globals_match or not emsg_match: + log.info( + f"Codejail darklaunch had mismatch for course={limit_overrides_context!r}, slug={slug!r}:\n" + f"{emsg_match=}, {globals_match=}\n" + f"Local: globals={globals_local!r}, emsg={emsg_local!r}\n" + f"Remote: globals={globals_remote!r}, emsg={emsg_remote!r}" + ) + # .. custom_attribute_name: codejail.darklaunch.globals_match # .. custom_attribute_description: True if local and remote globals_dict # values match, False otherwise. 'N/A' when either arm raised an diff --git a/xmodule/capa/safe_exec/tests/test_safe_exec.py b/xmodule/capa/safe_exec/tests/test_safe_exec.py index 05757b6afb79..d09f8c9d9ba7 100644 --- a/xmodule/capa/safe_exec/tests/test_safe_exec.py +++ b/xmodule/capa/safe_exec/tests/test_safe_exec.py @@ -195,7 +195,10 @@ def run_dark_launch( mock_remote_exec.side_effect = remote try: - safe_exec("", globals_dict) + safe_exec( + "", globals_dict, + limit_overrides_context="course-v1:org+course+run", slug="hw1", + ) except BaseException as e: safe_exec_e = e else: @@ -215,8 +218,8 @@ def run_dark_launch( # These don't change between the tests standard_codejail_attr_calls = [ - call('codejail.slug', None), - call('codejail.limit_overrides_context', None), + call('codejail.slug', 'hw1'), + call('codejail.limit_overrides_context', 'course-v1:org+course+run'), call('codejail.extra_files_count', 0), ] @@ -256,12 +259,11 @@ def remote_exec(data): ], expect_log_info_calls=[ call( - "Codejail darklaunch local results for slug=None: globals={'overwrite': 'mock local'}, " - "emsg=None, exception=None" - ), - call( - "Codejail darklaunch remote results for slug=None: globals={'overwrite': 'mock remote'}, " - "emsg=None, exception=None" + "Codejail darklaunch had mismatch for " + "course='course-v1:org+course+run', slug='hw1':\n" + "emsg_match=True, globals_match=False\n" + "Local: globals={'overwrite': 'mock local'}, emsg=None\n" + "Remote: globals={'overwrite': 'mock remote'}, emsg=None" ), ], # Should only see behavior of local exec @@ -296,12 +298,10 @@ def remote_exec(data): ], expect_log_info_calls=[ call( - "Codejail darklaunch local results for slug=None: globals={}, " - "emsg='unexpected', exception=BaseException('unexpected')" - ), - call( - "Codejail darklaunch remote results for slug=None: globals={}, " - "emsg=None, exception=None" + "Codejail darklaunch had unexpected exception " + "for course='course-v1:org+course+run', slug='hw1':\n" + "Local exception: BaseException('unexpected')\n" + "Remote exception: None" ), ], expect_globals_contains={}, @@ -332,12 +332,11 @@ def remote_exec(data): ], expect_log_info_calls=[ call( - "Codejail darklaunch local results for slug=None: globals={}, " - "emsg='oops', exception=None" - ), - call( - "Codejail darklaunch remote results for slug=None: globals={}, " - "emsg='OH NO', exception=None" + "Codejail darklaunch had mismatch for " + "course='course-v1:org+course+run', slug='hw1':\n" + "emsg_match=False, globals_match=True\n" + "Local: globals={}, emsg='oops'\n" + "Remote: globals={}, emsg='OH NO'" ), ], expect_globals_contains={}, @@ -351,7 +350,7 @@ def local_exec(code, globals_dict, **kwargs): raise SafeExecException("stack trace involving /tmp/codejail-1234567/whatever.py") def remote_exec(data): - emsg = "stack trace involving /tmp/codejail-abcdefgh/whatever.py" + emsg = "stack trace involving /tmp/codejail-abcd_EFG/whatever.py" return (emsg, SafeExecException(emsg)) results = self.run_dark_launch( @@ -365,47 +364,77 @@ def remote_exec(data): call('codejail.darklaunch.globals_match', True), call('codejail.darklaunch.emsg_match', True), # even though not exact match ], - expect_log_info_calls=[ - call( - "Codejail darklaunch local results for slug=None: globals={}, " - "emsg='stack trace involving /tmp/codejail-1234567/whatever.py', exception=None" - ), - call( - "Codejail darklaunch remote results for slug=None: globals={}, " - "emsg='stack trace involving /tmp/codejail-abcdefgh/whatever.py', exception=None" - ), - ], + expect_log_info_calls=[], expect_globals_contains={}, ) assert isinstance(results['raised'], SafeExecException) assert 'whatever.py' in repr(results['raised']) + def test_default_normalizers(self): + """ + Default normalizers handle false mismatches we've observed. + + This just provides coverage for some of the more complicated patterns. + """ + side_1 = ( + 'Couldn\'t execute jailed code: stdout: b\'\', stderr: b\'Traceback' + ' (most recent call last):\\n File "/tmp/codejail-9g9715g_/jailed_code"' + ', line 19, in \\n exec(code, g_dict)\\n File ""' + ', line 1, in \\n File "", line 89, in test_add\\n' + ' File "", line 1\\n import random random.choice(range(10))' + '\\n ^\\nSyntaxError: invalid syntax\\n\' with status code: 1' + ) + side_2 = ( + 'Couldn\'t execute jailed code: stdout: b\'\', stderr: b\'Traceback' + ' (most recent call last):\\n File "jailed_code"' + ', line 19, in \\n exec(code, g_dict)\\n File ""' + ', line 203, in \\n File "", line 89, in test_add\\n' + ' File "", line 1\\n import random random.choice(range(10))' + '\\n ^^^^^^\\nSyntaxError: invalid syntax\\n\' with status code: 1' + ) + assert normalize_error_message(side_1) == normalize_error_message(side_2) + @override_settings(CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS=[ - { - 'search': r'/tmp/codejail-[0-9a-zA-Z]+', - 'replace': r'/tmp/codejail-', - }, { 'search': r'[0-9]+', 'replace': r'', }, ]) def test_configurable_normalizers(self): - """We can override the normalizers, and they run in order.""" + """We can augment the normalizers, and they run in order.""" + emsg_in = "Error in /tmp/codejail-1234abcd/whatever.py: something 12 34 other" + expect_out = "Error in /tmp/codejail-/whatever.py: something other" + assert expect_out == normalize_error_message(emsg_in) + + @override_settings( + CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS=[ + { + 'search': r'[0-9]+', + 'replace': r'', + }, + ], + CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE='replace', + ) + def test_can_replace_normalizers(self): + """We can replace the normalizers.""" emsg_in = "Error in /tmp/codejail-1234abcd/whatever.py: something 12 34 other" - expect_out = "Error in /tmp/codejail-/whatever.py: something other" + expect_out = "Error in /tmp/codejail-abcd/whatever.py: something other" assert expect_out == normalize_error_message(emsg_in) @override_settings(CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS=[ { - 'search': r'broken [', - 'replace': r'replace', + 'search': r'broken', + 'replace': r'replace \g<>', # invalid replacement pattern }, ]) @patch('xmodule.capa.safe_exec.safe_exec.record_exception') - def test_normalizers_validate(self, mock_record_exception): - """Normalizers are validated, and fall back to empty list on error.""" - assert emsg_normalizers() == [] # pylint: disable=use-implicit-booleaness-not-comparison + @patch('xmodule.capa.safe_exec.safe_exec.log.error') + def test_normalizers_validate(self, mock_log_error, mock_record_exception): + """Normalizers are validated, and fall back to default list on error.""" + assert len(emsg_normalizers()) > 0 # pylint: disable=use-implicit-booleaness-not-comparison + mock_log_error.assert_called_once_with( + "Could not load custom codejail darklaunch emsg normalizers" + ) mock_record_exception.assert_called_once() diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index 1b2d18cf1770..1a096e76b22d 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -726,7 +726,7 @@ def generate_report_data(self, user_state_iterator, limit_responses=None): # For the purposes of this report, we don't need to support those use cases. anonymous_student_id=None, cache=None, - can_execute_unsafe_code=lambda: None, + can_execute_unsafe_code=lambda: False, get_python_lib_zip=( lambda: SandboxService(contentstore, self.scope_ids.usage_id.context_key).get_python_lib_zip() ), diff --git a/xmodule/item_bank_block.py b/xmodule/item_bank_block.py index 7adf935e48ee..b53617e2c8e4 100644 --- a/xmodule/item_bank_block.py +++ b/xmodule/item_bank_block.py @@ -512,7 +512,7 @@ def author_view(self, context): # Show a summary message and instructions. summary_html = loader.render_django_template('templates/item_bank/author_view.html', { # Due to template interpolation limitations, we have to pass some HTML for the link here: - "view_link": f'', + "view_link": f'', "blocks": [ {"display_name": display_name_with_default(child)} for child in self.get_children() diff --git a/xmodule/seq_block.py b/xmodule/seq_block.py index 1b94f47d898c..f06d3030f5e9 100644 --- a/xmodule/seq_block.py +++ b/xmodule/seq_block.py @@ -557,7 +557,19 @@ def _get_render_metadata(self, context, children, prereq_met, prereq_meta_info, 'This section is a prerequisite. You must complete this section in order to unlock additional content.' ) - blocks = self._render_student_view_for_blocks(context, children, fragment, view) if prereq_met else [] + if prereq_met: + blocks = self._render_student_view_for_blocks(context, children, fragment, view) + else: + blocks = [] + for child in children: + usage_id = child.scope_ids.usage_id + blocks.append({ + 'id': str(usage_id), + 'type': child.scope_ids.block_type, + 'display_name': child.display_name_with_default, + 'is_gated': True, # Mark as blocked + 'content': '', # Real content not included + }) params = { 'items': blocks, diff --git a/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css b/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css index 85ca354eb99f..bc1b79733fdf 100644 --- a/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css @@ -25,3 +25,6 @@ font-size: 0.85em; display: block; } +.xmodule_display.xmodule_WordCloudBlock .hd.hd-3 { + font-weight: bold !important; +} diff --git a/xmodule/tests/test_sequence.py b/xmodule/tests/test_sequence.py index be773865a717..c299f7bed0d7 100644 --- a/xmodule/tests/test_sequence.py +++ b/xmodule/tests/test_sequence.py @@ -478,3 +478,97 @@ def get_context_dict_from_string(self, data): # Replace tuple and un-necessary info from inside string and get the dictionary. cleaned_data = data.replace("(('seq_block.html',\n", '').replace("),\n {})", '').strip() return ast.literal_eval(cleaned_data) + + def test_not_gated_blocks_rendered_normally(self): + """ + Test that non-gated blocks are rendered with full content when prerequisites are met. + """ + # Mock child block + child = Mock() + child.scope_ids.usage_id = "block1" + child.scope_ids.block_type = "vertical" + child.display_name_with_default = "Test Block" + children = [child] + + # Mock context + context = {"next_url": "next_url", "prev_url": "prev_url"} + fragment = Mock() + + # Mock `_render_student_view_for_blocks` + self.sequence_3_1._render_student_view_for_blocks = Mock(return_value="rendered_blocks") # pylint: disable=protected-access + + # Call `_get_render_metadata` with prerequisites met + metadata = self.sequence_3_1._get_render_metadata( # pylint: disable=protected-access + context, children, prereq_met=True, prereq_meta_info={}, fragment=fragment + ) + + # Assert that blocks are rendered normally + assert metadata["items"] == "rendered_blocks" + assert metadata["next_url"] == "next_url" + assert metadata["prev_url"] == "prev_url" + + def test_gated_blocks_rendered_with_basic_info(self): + """ + Test that gated blocks are rendered with minimal metadata when prerequisites are not met. + """ + # Mock child block + child = Mock() + child.scope_ids.usage_id = "block1" + child.scope_ids.block_type = "vertical" + child.display_name_with_default = "Test Block" + children = [child] + + # Mock context + context = {"next_url": "next_url", "prev_url": "prev_url"} + + # Mock prereq_meta_info with required keys + prereq_meta_info = { + "url": "http://example.com/prereq", + "display_name": "Prerequisite Section", + "id": "prereq_block_id", + } + + # Call `_get_render_metadata` with prerequisites not met + metadata = self.sequence_3_1._get_render_metadata( # pylint: disable=protected-access + context, children, prereq_met=False, prereq_meta_info=prereq_meta_info + ) + + # Assert that gated blocks are rendered with basic info + assert len(metadata["items"]) == 1 + assert metadata["items"][0]["id"] == "block1" + assert metadata["items"][0]["type"] == "vertical" + assert metadata["items"][0]["display_name"] == "Test Block" + assert metadata["items"][0]["is_gated"] is True + assert metadata["items"][0]["content"] == "" + + # Assert that next and previous URLs are present + assert metadata["next_url"] == "next_url" + assert metadata["prev_url"] == "prev_url" + + def test_prereqs_met_content_rendered_normally(self): + """ + Test that content is rendered normally when prerequisites are met. + """ + # Mock child block + child = Mock() + child.scope_ids.usage_id = "block1" + child.scope_ids.block_type = "vertical" + child.display_name_with_default = "Test Block" + children = [child] + + # Mock context + context = {"next_url": "next_url", "prev_url": "prev_url"} + fragment = Mock() + + # Mock `_render_student_view_for_blocks` + self.sequence_3_1._render_student_view_for_blocks = Mock(return_value="rendered_blocks") # pylint: disable=protected-access + + # Call `_get_render_metadata` with prerequisites met + metadata = self.sequence_3_1._get_render_metadata( # pylint: disable=protected-access + context, children, prereq_met=True, prereq_meta_info={}, fragment=fragment + ) + + # Assert that content is rendered normally + assert metadata["items"] == "rendered_blocks" + assert metadata["next_url"] == "next_url" + assert metadata["prev_url"] == "prev_url" diff --git a/xmodule/util/sandboxing.py b/xmodule/util/sandboxing.py index 12c4243acfc5..a8883ba3e93d 100644 --- a/xmodule/util/sandboxing.py +++ b/xmodule/util/sandboxing.py @@ -7,6 +7,18 @@ DEFAULT_PYTHON_LIB_FILENAME = 'python_lib.zip' +def course_code_library_asset_name(): + """ + Return the asset name to use for course code libraries, defaulting to python_lib.zip. + """ + # .. setting_name: PYTHON_LIB_FILENAME + # .. setting_default: python_lib.zip + # .. setting_description: Name of the course file to make available to code in + # custom Python-graded problems. By default, this file will not be downloadable + # by learners. + return getattr(settings, 'PYTHON_LIB_FILENAME', DEFAULT_PYTHON_LIB_FILENAME) + + def can_execute_unsafe_code(course_id): """ Determine if this course is allowed to run unsafe code. @@ -34,7 +46,7 @@ def can_execute_unsafe_code(course_id): def get_python_lib_zip(contentstore, course_id): """Return the bytes of the course code library file, if it exists.""" - python_lib_filename = getattr(settings, 'PYTHON_LIB_FILENAME', DEFAULT_PYTHON_LIB_FILENAME) + python_lib_filename = course_code_library_asset_name() asset_key = course_id.make_asset_key("asset", python_lib_filename) zip_lib = contentstore().find(asset_key, throw_on_not_found=False) if zip_lib is not None: