diff --git a/cms/djangoapps/contentstore/course_admin.py b/cms/djangoapps/contentstore/course_admin.py
new file mode 100644
index 000000000000..3eb67f78341d
--- /dev/null
+++ b/cms/djangoapps/contentstore/course_admin.py
@@ -0,0 +1,496 @@
+"""Services for the private Studio course administration page.
+
+Reruns use Studio's existing cloning task. SQL stores only the reservation,
+enrollment modes and certificate configuration; learner records are never copied.
+"""
+
+import json
+import logging
+import re
+from copy import deepcopy
+from datetime import datetime, timezone
+
+from django.core.exceptions import PermissionDenied, ValidationError
+from django.db import IntegrityError, transaction
+from django.utils.dateparse import parse_datetime
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import CourseKey
+from opaque_keys.edx.locator import CourseLocator
+
+from common.djangoapps.course_action_state.models import CourseRerunState
+from common.djangoapps.course_modes.models import CourseMode
+from lms.djangoapps.certificates.api import (
+ create_course_certificate_generation_settings,
+ get_course_certificate_generation_settings,
+)
+from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
+from xmodule.modulestore import EdxJSONEncoder, ModuleStoreEnum
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.exceptions import ItemNotFoundError
+
+LOGGER = logging.getLogger(__name__)
+RUN_PATTERN = re.compile(r"^(\d{4})_C([1-9]\d*)$", re.IGNORECASE)
+DATE_FIELDS = frozenset(('start', 'end', 'enrollment_start', 'enrollment_end', 'certificate_available_date'))
+BOOLEAN_FIELDS = frozenset(('self_paced', 'invitation_only'))
+COURSE_FIELDS = DATE_FIELDS | BOOLEAN_FIELDS | {'catalog_visibility'}
+SETTINGS_FIELDS = COURSE_FIELDS | {'mode', 'certificate_mode', 'shift_content_dates'}
+
+
+def _require_admin(user):
+ """Keep service calls as restricted as the HTTP entry points."""
+ if not user.is_authenticated or not user.is_active or not user.is_staff:
+ raise PermissionDenied()
+
+
+def validate_settings(payload, require_dates=False):
+ """Validate the small, explicit settings contract; omitted values stay unchanged.
+
+ Date-only and timezone-less inputs use UTC. The page sends offsets explicitly.
+ Empty optional dates clear their field; start and end cannot be cleared.
+ """
+ if not isinstance(payload, dict):
+ raise ValidationError('Настройки должны быть объектом JSON.')
+ unknown = set(payload) - SETTINGS_FIELDS
+ if unknown:
+ raise ValidationError(f'Неизвестные настройки: {", ".join(sorted(unknown))}.')
+ result = dict(payload)
+ for name in DATE_FIELDS & result.keys():
+ value = result[name]
+ if value in (None, ''):
+ result[name] = None
+ else:
+ try:
+ parsed = value if isinstance(value, datetime) else parse_datetime(value)
+ except (TypeError, ValueError) as exc:
+ raise ValidationError(f'Некорректная дата: {name}.') from exc
+ if parsed is None:
+ raise ValidationError(f'Некорректная дата: {name}.')
+ result[name] = (
+ parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc)
+ )
+ for name in ('start', 'end'):
+ if (require_dates or name in result) and result.get(name) is None:
+ raise ValidationError(f'Укажите дату {name}.')
+ for name in (BOOLEAN_FIELDS | {'shift_content_dates'}) & result.keys():
+ if not isinstance(result[name], bool):
+ raise ValidationError(f'{name}: требуется true или false.')
+ choices = {
+ 'catalog_visibility': ('both', 'about', 'none'),
+ 'mode': ('copy', 'honor', 'audit'),
+ 'certificate_mode': ('copy', 'enabled', 'disabled'),
+ }
+ for name, allowed in choices.items():
+ if name in result and result[name] not in allowed:
+ raise ValidationError(f'Недопустимое значение {name}.')
+ _validate_dates(result)
+ return result
+
+
+def _validate_dates(values):
+ """Validate complete or partial course/enrollment calendars."""
+ for start_name, end_name in (('start', 'end'), ('enrollment_start', 'enrollment_end')):
+ start, end = values.get(start_name), values.get(end_name)
+ if start and end and start >= end:
+ raise ValidationError(f'{end_name} должна быть позже {start_name}.')
+ if values.get('enrollment_end') and values.get('end') and values['enrollment_end'] > values['end']:
+ raise ValidationError('Окончание записи не может быть позже окончания курса.')
+ if values.get('enrollment_start') and values.get('end') and values['enrollment_start'] > values['end']:
+ raise ValidationError('Начало записи не может быть позже окончания курса.')
+
+
+def _source_course(raw_key):
+ """Require a real, unversioned split-store course."""
+ try:
+ key = raw_key if isinstance(raw_key, CourseKey) else CourseKey.from_string(raw_key)
+ except (InvalidKeyError, TypeError) as exc:
+ raise ValidationError('Некорректный идентификатор курса.') from exc
+ if (type(key) is not CourseLocator or key.deprecated # pylint: disable=unidiomatic-typecheck
+ or key.branch or key.version_guid):
+ raise ValidationError('Поддерживаются только курсы course-v1 без версии или ветки.')
+ course = modulestore().get_course(key, depth=0)
+ if course is None:
+ raise ValidationError('Курс не найден.')
+ return course
+
+
+def _known_course_keys():
+ """Include Mongo courses, SQL overviews and all outstanding run reservations."""
+ keys = {course.id for course in modulestore().get_course_summaries()}
+ keys.update(CourseOverview.objects.values_list('id', flat=True))
+ keys.update(CourseRerunState.objects.filter(action='rerun').values_list('course_key', flat=True))
+ return keys
+
+
+def next_course_key(source_key, year, known_keys):
+ """Choose the next numeric YYYY_Cn within this organization/course/year."""
+ maximum = 0
+ for key in known_keys:
+ if not isinstance(key, CourseKey):
+ key = CourseKey.from_string(key)
+ if key.org.casefold() != source_key.org.casefold() or key.course.casefold() != source_key.course.casefold():
+ continue
+ match = RUN_PATTERN.fullmatch(key.run or '')
+ if match and int(match.group(1)) == year:
+ maximum = max(maximum, int(match.group(2)))
+ return CourseLocator(org=source_key.org, course=source_key.course, run=f'{year}_C{maximum + 1}')
+
+
+def _generation_values(course, settings):
+ """Resolve the latest certificate settings, requiring an active template to enable."""
+ original = get_course_certificate_generation_settings(course.id)
+ values = {
+ 'self_generation_enabled': original['self_generation_enabled'] if original else False,
+ 'language_specific_templates_enabled': original['language_specific_templates_enabled'] if original else False,
+ 'include_hours_of_effort': original['include_hours_of_effort'] if original else None,
+ }
+ policy = settings.get('certificate_mode', 'copy')
+ if policy != 'copy':
+ values['self_generation_enabled'] = policy == 'enabled'
+ if values['self_generation_enabled']:
+ certificates = (course.certificates or {}).get('certificates', [])
+ if not any(certificate.get('is_active') for certificate in certificates):
+ raise ValidationError('Сначала настройте и активируйте сертификат курса в Studio.')
+ return values
+
+
+def _settings_for_course(course, settings, rerun=False):
+ """Validate resulting dates, including unchanged values on an existing course."""
+ values = {name: getattr(course, name) for name in DATE_FIELDS}
+ if rerun:
+ values.update(enrollment_start=None, enrollment_end=None, certificate_available_date=None)
+ values.update(settings)
+ _validate_dates(values)
+ if rerun or settings.get('certificate_mode') == 'enabled':
+ _generation_values(course, settings)
+
+
+def _result(source_key, course_key=None, state='failed', message='', state_id=None):
+ return {
+ 'source_course_id': str(source_key),
+ 'course_id': str(course_key) if course_key else None,
+ 'state': state,
+ 'message': message,
+ 'state_id': state_id,
+ }
+
+
+def _error_message(exc):
+ if isinstance(exc, ValidationError):
+ return ' '.join(exc.messages)
+ return 'Операция не выполнена. Подробности записаны в журнал Studio.'
+
+
+def _prepare_course(raw_key, settings, seen):
+ course = _source_course(raw_key)
+ family = (course.id.org.casefold(), course.id.course.casefold())
+ if family in seen:
+ raise ValidationError('Выберите только один исходный запуск для одного курса.')
+ seen.add(family)
+ _settings_for_course(course, settings, rerun=True)
+ return course
+
+
+def preview_course_reruns(user, course_keys, settings):
+ """Preflight each selection without reserving a key or starting a task."""
+ _require_admin(user)
+ settings = validate_settings(settings, require_dates=True)
+ known, seen, results = _known_course_keys(), set(), []
+ for raw_key in course_keys:
+ try:
+ course = _prepare_course(raw_key, settings, seen)
+ destination = next_course_key(course.id, settings['start'].year, known)
+ known.add(destination)
+ results.append(_result(course.id, destination, 'preview', 'Готов к перезапуску.'))
+ except Exception as exc: # pylint: disable=broad-except
+ LOGGER.exception('Course admin preflight failed for %s', raw_key)
+ results.append(_result(raw_key, message=_error_message(exc)))
+ return results
+
+
+def _reserve_rerun(course, user, year, known, expected=None):
+ """The SQL unique constraint arbitrates simultaneous requests for the next run.
+
+Never use the existing manager's initiated()/get_or_create(): it overwrites a
+competing worker's reservation. Each collision gets a separate savepoint.
+"""
+ if expected:
+ try:
+ expected = CourseKey.from_string(expected)
+ except (InvalidKeyError, TypeError) as exc:
+ raise ValidationError('Некорректный идентификатор из предварительного просмотра.') from exc
+ match = RUN_PATTERN.fullmatch(expected.run or '')
+ if (type(expected) is not CourseLocator # pylint: disable=unidiomatic-typecheck
+ or expected.branch or expected.version_guid
+ or expected.org != course.id.org or expected.course != course.id.course
+ or not match or int(match.group(1)) != year):
+ raise ValidationError('Идентификатор запуска не соответствует выбранному курсу и году.')
+ previous = CourseRerunState.objects.filter(course_key=expected, action='rerun').first()
+ if previous:
+ if previous.source_course_key == course.id and previous.created_user_id == user.id:
+ return previous, False
+ raise ValidationError('Этот запуск уже зарезервирован. Обновите предварительный просмотр.')
+ if expected != next_course_key(course.id, year, known):
+ raise ValidationError('Список запусков изменился. Обновите предварительный просмотр.')
+ for _ in range(1000):
+ destination = expected or next_course_key(course.id, year, known)
+ known.add(destination)
+ if modulestore().has_course(destination, ignore_case=True):
+ if expected:
+ raise ValidationError('Курс уже существует. Обновите предварительный просмотр.')
+ continue
+ try:
+ with transaction.atomic():
+ reservation = CourseRerunState.objects.create(
+ course_key=destination,
+ action='rerun',
+ state='in_progress',
+ source_course_key=course.id,
+ created_user=user,
+ updated_user=user,
+ display_name=course.display_name or str(course.id),
+ should_display=True,
+ message='',
+ )
+ return reservation, True
+ except IntegrityError:
+ # Another request reserved this run between the read and insert.
+ if expected:
+ previous = CourseRerunState.objects.filter(course_key=expected, action='rerun').first()
+ if previous and previous.source_course_key == course.id and previous.created_user_id == user.id:
+ return previous, False
+ raise ValidationError('Этот запуск уже зарезервирован. Обновите предварительный просмотр.') from None
+ continue
+ raise ValidationError('Не удалось зарезервировать запуск. Повторите запрос.')
+
+
+def queue_course_reruns(user, course_keys, settings, expected_course_ids=None):
+ """Reserve distinct destinations and dispatch one existing Studio task per course."""
+ from cms.djangoapps.contentstore.tasks import rerun_course # pylint: disable=import-outside-toplevel
+
+ _require_admin(user)
+ settings = validate_settings(settings, require_dates=True)
+ if expected_course_ids is not None:
+ if isinstance(expected_course_ids, list) and len(expected_course_ids) == len(course_keys):
+ expected_course_ids = dict(zip(map(str, course_keys), expected_course_ids))
+ if not isinstance(expected_course_ids, dict) or set(expected_course_ids) != set(map(str, course_keys)):
+ raise ValidationError('Предварительный просмотр должен включать все выбранные курсы.')
+ if not all(isinstance(value, str) and value for value in expected_course_ids.values()):
+ raise ValidationError('Предварительный просмотр содержит некорректные идентификаторы.')
+ known, seen, results = _known_course_keys(), set(), []
+ for raw_key in course_keys:
+ reservation = None
+ try:
+ course = _prepare_course(raw_key, settings, seen)
+ reservation, created = _reserve_rerun(
+ course, user, settings['start'].year, known,
+ expected=expected_course_ids[str(raw_key)] if expected_course_ids is not None else None,
+ )
+ destination = reservation.course_key
+ if not created:
+ results.append(_result(
+ course.id, destination, reservation.state,
+ 'Этот запрос уже принят; новый запуск не создан.', reservation.pk,
+ ))
+ continue
+ fields = {name: value for name, value in settings.items() if name in COURSE_FIELDS}
+ fields.update(
+ display_name=course.display_name,
+ wiki_slug=f'{destination.org}.{destination.course}.{destination.run}',
+ advertised_start=None,
+ video_upload_pipeline={},
+ )
+ for name in ('enrollment_start', 'enrollment_end', 'certificate_available_date'):
+ fields.setdefault(name, None)
+ if settings.get('certificate_mode') == 'enabled':
+ fields['cert_html_view_enabled'] = True
+ result = _result(course.id, destination, 'in_progress', 'Задача поставлена в очередь.', reservation.pk)
+ # Enqueue only once the unique reservation is committed. With autocommit
+ # this executes immediately; it also remains safe under ATOMIC_REQUESTS.
+
+ def dispatch(state=reservation, source=course.id, overrides=fields, row=result):
+ try:
+ rerun_course.delay(
+ str(source), str(state.course_key), user.id,
+ json.dumps(overrides, cls=EdxJSONEncoder),
+ admin_settings=json.loads(json.dumps(settings, cls=EdxJSONEncoder)),
+ )
+ except Exception: # pylint: disable=broad-except
+ LOGGER.exception('Could not enqueue admin rerun for %s', state.course_key)
+ CourseRerunState.objects.failed(course_key=state.course_key)
+ row.update(state='failed', message='Не удалось отправить задачу в очередь. Проверьте Celery.')
+ transaction.on_commit(dispatch)
+ results.append(result)
+ except Exception as exc: # pylint: disable=broad-except
+ LOGGER.exception('Course admin rerun failed for %s', raw_key)
+ if reservation:
+ CourseRerunState.objects.failed(course_key=reservation.course_key)
+ results.append(_result(
+ raw_key, reservation.course_key if reservation else None,
+ message=_error_message(exc), state_id=reservation.pk if reservation else None,
+ ))
+ return results
+
+
+def _ensure_mode(course_key, slug):
+ """Add a free mode without removing or modifying existing paid tracks."""
+ CourseMode.objects.get_or_create(
+ course_id=course_key, mode_slug=slug, currency='usd',
+ defaults={'mode_display_name': 'Honor' if slug == 'honor' else 'Audit', 'min_price': 0},
+ )
+
+
+def configure_rerun(source_key, destination_key, settings):
+ """Copy only course-level SQL settings after the Studio clone has completed.
+
+Caller wraps this and its final success state in the same SQL transaction.
+The clone already carries grading, content, active certificate design and other
+Studio course fields. Ecommerce identifiers belong to the source product and
+must be provisioned separately for a new course run.
+"""
+ settings = validate_settings(settings, require_dates=True)
+ source = _source_course(source_key)
+ generation_values = _generation_values(source, settings)
+ CourseOverview.get_from_id(destination_key)
+ policy = settings.get('mode', 'copy')
+ if policy == 'copy':
+ modes = list(CourseMode.objects.filter(course_id=source_key))
+ for mode in modes:
+ values = {
+ field.name: getattr(mode, field.name)
+ for field in CourseMode._meta.concrete_fields
+ if field.name not in ('id', 'course', 'mode_slug', 'currency')
+ }
+ delta = settings['start'] - source.start if source.start else None
+ if delta and values['_expiration_datetime']:
+ values['_expiration_datetime'] += delta
+ if delta and values['expiration_date']:
+ values['expiration_date'] += delta
+ for name in ('sku', 'android_sku', 'ios_sku', 'bulk_sku'):
+ values[name] = None
+ CourseMode.objects.update_or_create(
+ course_id=destination_key, mode_slug=mode.mode_slug, currency=mode.currency, defaults=values,
+ )
+ if not modes:
+ default = CourseMode.DEFAULT_MODE
+ CourseMode.objects.get_or_create(
+ course_id=destination_key, mode_slug=default.slug, currency=default.currency,
+ defaults={
+ 'mode_display_name': default.name, 'min_price': default.min_price,
+ 'suggested_prices': default.suggested_prices, 'description': default.description,
+ },
+ )
+ else:
+ _ensure_mode(destination_key, policy)
+ create_course_certificate_generation_settings(destination_key, generation_values)
+
+
+def shift_rerun_content_dates(source_key, destination_key, user_id, settings):
+ """Shift explicit section/component dates in each branch without publishing drafts.
+
+Use the split store's branch-local update primitive intentionally: the normal
+Studio update auto-publishes chapters/sequentials, replacing published content
+with draft content. The two existing branches must remain independent here.
+Advanced schedules embedded in third-party XBlock JSON are not rewritten.
+"""
+ if not settings.get('shift_content_dates', True):
+ return
+ from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore # pylint: disable=import-outside-toplevel
+
+ settings = validate_settings(settings, require_dates=True)
+ source = _source_course(source_key)
+ if not source.start:
+ return
+ delta = settings['start'] - source.start
+ if not delta:
+ return
+ store = modulestore()._get_modulestore_for_courselike(destination_key) # pylint: disable=protected-access
+ with store.bulk_operations(destination_key):
+ published_changed = False
+ for branch in (ModuleStoreEnum.BranchName.published, ModuleStoreEnum.BranchName.draft):
+ try:
+ blocks = store.get_items(destination_key.for_branch(branch))
+ except ItemNotFoundError:
+ # A draft-only source may have no published branch.
+ continue
+ for block in blocks:
+ if block.category == 'course':
+ continue
+ changed = False
+ for name in ('start', 'due'):
+ field = block.fields.get(name)
+ if field and field.is_set_on(block) and getattr(block, name) is not None:
+ setattr(block, name, getattr(block, name) + delta)
+ changed = True
+ if changed:
+ # Drop the snapshot version so multiple updates advance this
+ # branch's current head instead of writing detached versions.
+ block.location = block.location.version_agnostic()
+ SplitMongoModuleStore.update_item(store, block, user_id)
+ published_changed = published_changed or branch == ModuleStoreEnum.BranchName.published
+ if published_changed:
+ store._flag_publish_event(destination_key) # pylint: disable=protected-access
+
+
+def save_course_settings(user, course_keys, settings):
+ """Apply selected fields to each existing course; preserve unselected settings."""
+ _require_admin(user)
+ settings = validate_settings(settings)
+ results, seen = [], set()
+ for raw_key in course_keys:
+ previous_fields = None
+ try:
+ course = _source_course(raw_key)
+ if course.id in seen:
+ raise ValidationError('Курс указан повторно.')
+ seen.add(course.id)
+ _settings_for_course(course, settings)
+ with transaction.atomic():
+ CourseOverview.get_from_id(course.id)
+ # Serialize SQL configuration updates for this existing course.
+ CourseOverview.objects.select_for_update().get(id=course.id)
+ course = _source_course(course.id)
+ _settings_for_course(course, settings)
+ if settings.get('mode', 'copy') != 'copy':
+ _ensure_mode(course.id, settings['mode'])
+ generation_values = None
+ if settings.get('certificate_mode', 'copy') != 'copy':
+ generation_values = _generation_values(course, settings)
+ touched = set(settings) & COURSE_FIELDS
+ if settings.get('certificate_mode') == 'enabled':
+ touched.add('cert_html_view_enabled')
+ previous_fields = {
+ name: (course.fields[name].is_set_on(course), deepcopy(getattr(course, name)))
+ for name in touched
+ }
+ for name, value in settings.items():
+ if name in COURSE_FIELDS:
+ setattr(course, name, value)
+ if settings.get('certificate_mode') == 'enabled':
+ course.cert_html_view_enabled = True
+ modulestore().update_item(course, user.id)
+ CourseOverview.load_from_module_store(course.id)
+ if generation_values is not None:
+ create_course_certificate_generation_settings(course.id, generation_values)
+ results.append(_result(course.id, course.id, 'saved', 'Настройки сохранены.'))
+ except Exception as exc: # pylint: disable=broad-except
+ LOGGER.exception('Course admin settings failed for %s', raw_key)
+ message = _error_message(exc)
+ if previous_fields:
+ # SQL rolled back above, but Mongo does not participate in its
+ # transaction. Restore precisely the touched fields on failure.
+ try:
+ with transaction.atomic():
+ CourseOverview.objects.select_for_update().get(id=course.id)
+ restored = _source_course(course.id)
+ for name, (was_set, value) in previous_fields.items():
+ if was_set:
+ setattr(restored, name, value)
+ else:
+ delattr(restored, name)
+ modulestore().update_item(restored, user.id)
+ CourseOverview.load_from_module_store(restored.id)
+ except Exception: # pylint: disable=broad-except
+ LOGGER.exception('Could not restore course settings for %s', raw_key)
+ message += ' Часть настроек могла сохраниться; проверьте курс в Studio.'
+ results.append(_result(raw_key, raw_key, message=message))
+ return results
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/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/tasks.py b/cms/djangoapps/contentstore/tasks.py
index b6cb2af53d56..60976e556ade 100644
--- a/cms/djangoapps/contentstore/tasks.py
+++ b/cms/djangoapps/contentstore/tasks.py
@@ -22,6 +22,7 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
from django.core.files import File
+from django.db import transaction
from django.test import RequestFactory
from django.utils.text import get_valid_filename
from edx_django_utils.monitoring import (
@@ -131,12 +132,72 @@ def clone_instance(instance, field_values):
return instance
+def copy_custom_course_overview_fields(source_course_key, destination_course_key, raise_on_error=False):
+ """
+ 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,
+ )
+ if raise_on_error:
+ raise
+
+
@shared_task
@set_code_owner_attribute
-def rerun_course(source_course_key_string, destination_course_key_string, user_id, fields=None):
+def rerun_course(source_course_key_string, destination_course_key_string, user_id, fields=None, admin_settings=None):
"""
Reruns a course in a new celery task.
"""
+ if admin_settings is not None:
+ # Celery may redeliver a task. Hold the destination reservation lock so
+ # concurrent deliveries cannot clone or overwrite one another's status.
+ with transaction.atomic():
+ state = CourseRerunState.objects.select_for_update().get(
+ course_key=CourseKey.from_string(destination_course_key_string), action='rerun',
+ )
+ if state.state != 'in_progress':
+ return state.state
+ return _rerun_course(
+ source_course_key_string, destination_course_key_string, user_id, fields, admin_settings,
+ )
+ return _rerun_course(source_course_key_string, destination_course_key_string, user_id, fields)
+
+
+def _rerun_course(source_course_key_string, destination_course_key_string, user_id, fields=None, admin_settings=None):
+ """Implementation shared by native Studio reruns and reserved admin reruns."""
# import here, at top level this import prevents the celery workers from starting up correctly
from edxval.api import copy_course_videos
@@ -152,34 +213,53 @@ 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)
- update_unit_discussion_state_from_discussion_blocks(destination_course_key, user_id)
+ if admin_settings is not None:
+ from cms.djangoapps.contentstore.course_admin import shift_rerun_content_dates
+ shift_rerun_content_dates(source_course_key, destination_course_key, user_id, admin_settings)
- # set initial permissions for the user to access the course.
- initialize_permissions(destination_course_key, User.objects.get(id=user_id))
+ copy_custom_course_overview_fields(
+ source_course_key,
+ destination_course_key,
+ raise_on_error=admin_settings is not None,
+ )
- # update state: Succeeded
- CourseRerunState.objects.succeeded(course_key=destination_course_key)
+ update_unit_discussion_state_from_discussion_blocks(destination_course_key, user_id)
# call edxval to attach videos to the rerun
copy_course_videos(source_course_key, destination_course_key)
- # Copy OrganizationCourse
- organization_course = OrganizationCourse.objects.filter(course_id=source_course_key_string).first()
-
- if organization_course:
- clone_instance(organization_course, {'course_id': destination_course_key_string})
-
- # Copy RestrictedCourse
- restricted_course = RestrictedCourse.objects.filter(course_key=source_course_key).first()
-
- if restricted_course:
- country_access_rules = CountryAccessRule.objects.filter(restricted_course=restricted_course)
- new_restricted_course = clone_instance(restricted_course, {'course_key': destination_course_key})
- for country_access_rule in country_access_rules:
- clone_instance(country_access_rule, {'restricted_course': new_restricted_course})
-
- org_data = ensure_organization(source_course_key.org)
- add_organization_course(org_data, destination_course_key)
+ # Success includes every copy and the admin workflow's SQL configuration.
+ # Keep SQL copies in one transaction, and apply admin configuration after
+ # modulestore signals so pacing handlers cannot overwrite certificate settings.
+ with transaction.atomic():
+ # Set initial permissions for the user to access the course.
+ creator = User.objects.get(id=user_id)
+ if admin_settings is not None:
+ # The private bulk workflow reserves directly instead of invoking the
+ # single-course HTTP helper that normally grants these roles.
+ CourseInstructorRole(destination_course_key).add_users(creator)
+ CourseStaffRole(destination_course_key).add_users(creator)
+ initialize_permissions(destination_course_key, creator)
+
+ # Copy OrganizationCourse
+ organization_course = OrganizationCourse.objects.filter(course_id=source_course_key_string).first()
+ if organization_course:
+ clone_instance(organization_course, {'course_id': destination_course_key_string})
+
+ # Copy RestrictedCourse
+ restricted_course = RestrictedCourse.objects.filter(course_key=source_course_key).first()
+ if restricted_course:
+ country_access_rules = CountryAccessRule.objects.filter(restricted_course=restricted_course)
+ new_restricted_course = clone_instance(restricted_course, {'course_key': destination_course_key})
+ for country_access_rule in country_access_rules:
+ clone_instance(country_access_rule, {'restricted_course': new_restricted_course})
+
+ org_data = ensure_organization(source_course_key.org)
+ add_organization_course(org_data, destination_course_key)
+ if admin_settings is not None:
+ from cms.djangoapps.contentstore.course_admin import configure_rerun
+ configure_rerun(source_course_key, destination_course_key, admin_settings)
+ CourseRerunState.objects.succeeded(course_key=destination_course_key)
return "succeeded"
except DuplicateCourseError:
diff --git a/cms/djangoapps/contentstore/tests/test_course_admin.py b/cms/djangoapps/contentstore/tests/test_course_admin.py
new file mode 100644
index 000000000000..3320666a4ede
--- /dev/null
+++ b/cms/djangoapps/contentstore/tests/test_course_admin.py
@@ -0,0 +1,133 @@
+"""Unit tests for the private course administration service."""
+
+from contextlib import nullcontext
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+from django.core.exceptions import PermissionDenied, ValidationError
+from django.test import SimpleTestCase
+from opaque_keys.edx.locator import CourseLocator
+
+from cms.djangoapps.contentstore import course_admin
+
+
+SOURCE = CourseLocator(org='FBB', course='BNJjTSOB', run='2026_C1')
+
+
+class CourseAdminSettingsTests(SimpleTestCase):
+ """The bulk API accepts only explicit, internally consistent settings."""
+
+ def test_dates_are_normalized_to_utc(self):
+ result = course_admin.validate_settings({
+ 'start': '2027-01-01T09:00:00+05:00',
+ 'end': '2027-05-31T09:00:00+05:00',
+ 'self_paced': True,
+ 'catalog_visibility': 'about',
+ }, require_dates=True)
+
+ self.assertEqual(result['start'], datetime(2027, 1, 1, 4, tzinfo=timezone.utc))
+ self.assertEqual(result['end'], datetime(2027, 5, 31, 4, tzinfo=timezone.utc))
+ self.assertTrue(result['self_paced'])
+
+ def test_rejects_unknown_settings_invalid_choices_and_non_boolean_values(self):
+ invalid_payloads = (
+ {'unexpected': 'value'},
+ {'catalog_visibility': 'public'},
+ {'mode': 'verified'},
+ {'certificate_mode': 'yes'},
+ {'self_paced': 'true'},
+ )
+ for payload in invalid_payloads:
+ with self.subTest(payload=payload), self.assertRaises(ValidationError):
+ course_admin.validate_settings(payload)
+
+ def test_rejects_inconsistent_course_and_enrollment_periods(self):
+ invalid_payloads = (
+ {'start': '2027-02-01T00:00:00Z', 'end': '2027-01-01T00:00:00Z'},
+ {'enrollment_start': '2027-03-01T00:00:00Z',
+ 'enrollment_end': '2027-02-01T00:00:00Z'},
+ {'end': '2027-03-01T00:00:00Z', 'enrollment_end': '2027-04-01T00:00:00Z'},
+ )
+ for payload in invalid_payloads:
+ with self.subTest(payload=payload), self.assertRaises(ValidationError):
+ course_admin.validate_settings(payload)
+
+ def test_service_rejects_non_admin_and_inactive_staff(self):
+ for user in (
+ SimpleNamespace(is_authenticated=False, is_active=True, is_staff=True),
+ SimpleNamespace(is_authenticated=True, is_active=True, is_staff=False),
+ SimpleNamespace(is_authenticated=True, is_active=False, is_staff=True),
+ ):
+ with self.subTest(user=user), self.assertRaises(PermissionDenied):
+ course_admin.preview_course_reruns(user, [], {
+ 'start': '2027-01-01T00:00:00Z', 'end': '2027-02-01T00:00:00Z',
+ })
+
+
+class CourseAdminRunTests(SimpleTestCase):
+ """Automatic run numbers work with numeric C suffixes and legacy sources."""
+
+ def test_next_run_uses_highest_numeric_suffix_for_year_and_family(self):
+ known = {
+ CourseLocator(org='FBB', course='BNJjTSOB', run='2027_C2'),
+ CourseLocator(org='fbb', course='bnjjtsob', run='2027_C10'),
+ CourseLocator(org='FBB', course='BNJjTSOB', run='2026_C99'),
+ CourseLocator(org='FBB', course='OTHER', run='2027_C50'),
+ CourseLocator(org='FBB', course='BNJjTSOB', run='2021-2022_C1'),
+ }
+
+ result = course_admin.next_course_key(SOURCE, 2027, known)
+
+ self.assertEqual(str(result), 'course-v1:FBB+BNJjTSOB+2027_C11')
+
+ def test_certificate_generation_requires_an_active_studio_certificate(self):
+ source = SimpleNamespace(id=SOURCE, certificates={'certificates': [{'is_active': False}]})
+ current = {
+ 'self_generation_enabled': True,
+ 'language_specific_templates_enabled': False,
+ 'include_hours_of_effort': None,
+ }
+ with patch.object(course_admin, 'get_course_certificate_generation_settings', return_value=current):
+ with self.assertRaises(ValidationError):
+ course_admin._generation_values(source, {'certificate_mode': 'copy'})
+
+ source.certificates['certificates'][0]['is_active'] = True
+ result = course_admin._generation_values(source, {'certificate_mode': 'copy'})
+
+ self.assertTrue(result['self_generation_enabled'])
+
+ def test_shift_content_dates_updates_published_and_draft_branches_independently(self):
+ source_start = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ published = self._block(datetime(2026, 2, 1, tzinfo=timezone.utc))
+ draft = self._block(datetime(2026, 2, 3, tzinfo=timezone.utc))
+ store = Mock()
+ store.bulk_operations.return_value = nullcontext()
+ store.get_items.side_effect = ([published], [draft])
+ mixed_store = Mock()
+ mixed_store._get_modulestore_for_courselike.return_value = store
+ settings = {
+ 'start': '2027-01-01T00:00:00Z',
+ 'end': '2027-05-31T00:00:00Z',
+ 'shift_content_dates': True,
+ }
+
+ with patch.object(course_admin, '_source_course', return_value=SimpleNamespace(start=source_start)), \
+ patch.object(course_admin, 'modulestore', return_value=mixed_store), \
+ patch('xmodule.modulestore.split_mongo.split.SplitMongoModuleStore.update_item') as update:
+ course_admin.shift_rerun_content_dates(SOURCE, SOURCE, 7, settings)
+
+ self.assertEqual(published.start, datetime(2027, 2, 1, tzinfo=timezone.utc))
+ self.assertEqual(draft.start, datetime(2027, 2, 3, tzinfo=timezone.utc))
+ self.assertEqual(update.call_count, 2)
+ store._flag_publish_event.assert_called_once_with(SOURCE)
+
+ @staticmethod
+ def _block(start):
+ date_field = Mock()
+ date_field.is_set_on.return_value = True
+ location = Mock()
+ location.version_agnostic.return_value = location
+ return SimpleNamespace(
+ category='chapter', fields={'start': date_field}, start=start, location=location,
+ )
diff --git a/cms/djangoapps/contentstore/views/course_admin.py b/cms/djangoapps/contentstore/views/course_admin.py
new file mode 100644
index 000000000000..03b3724fe59d
--- /dev/null
+++ b/cms/djangoapps/contentstore/views/course_admin.py
@@ -0,0 +1,285 @@
+"""Private Studio console for global administrators managing course runs."""
+
+import json
+import logging
+import re
+from datetime import timezone
+from functools import wraps
+
+from django.conf import settings
+from django.core import signing
+from django.core.exceptions import ValidationError
+from django.core.paginator import Paginator
+from django.http import JsonResponse
+from django.middleware.csrf import get_token
+from django.urls import reverse
+from django.views.decorators.cache import never_cache
+from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
+from django.views.decorators.http import require_GET, require_POST
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import CourseKey
+from opaque_keys.edx.locator import CourseLocator
+
+from cms.djangoapps.contentstore import course_admin as service
+from cms.djangoapps.contentstore.utils import get_schedule_details_url
+from common.djangoapps.course_action_state.models import CourseRerunState
+from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.edxmako.shortcuts import render_to_response
+from common.djangoapps.student.roles import GlobalStaff
+from lms.djangoapps.certificates.api import get_self_generation_enabled_for_courses
+from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
+from xmodule.modulestore.django import modulestore
+
+
+LOG = logging.getLogger(__name__)
+PREVIEW_SALT = 'studio.course-admin.preview.v1'
+RUN_PATTERN = re.compile(r'^(\d{4})_C([1-9]\d*)$', re.IGNORECASE)
+
+
+def admin_required(view):
+ """Check every endpoint, including reads, independently of navigation."""
+ @wraps(view)
+ def wrapped(request, *args, **kwargs):
+ if not (request.user.is_authenticated and request.user.is_active and GlobalStaff().has_user(request.user)):
+ return JsonResponse({'error': 'Доступ разрешён только администраторам платформы.'}, status=403)
+ return view(request, *args, **kwargs)
+ return wrapped
+
+
+def parse_filters(values):
+ """Accept bounded, literal filters from either a query string or JSON."""
+ if not isinstance(values, dict) and not hasattr(values, 'getlist'):
+ raise ValueError('Некорректные фильтры.')
+ result = {}
+ for field in ('q', 'org', 'run', 'year'):
+ value = values.get(field, '')
+ if not isinstance(value, str) or len(value) > 255:
+ raise ValueError('Некорректное значение фильтра.')
+ result[field] = value.strip()
+ if result['year'] and not re.fullmatch(r'[1-9]\d{3}', result['year']):
+ raise ValueError('Год должен состоять из четырёх цифр.')
+ latest = values.get('latest', True)
+ if latest not in (True, False, '1', '0', 'true', 'false', 'on', ''):
+ raise ValueError('Некорректный фильтр последнего запуска.')
+ result['latest'] = latest in (True, '1', 'true', 'on')
+ return result
+
+
+def filter_courses(courses, filters):
+ """Filter arbitrary legacy runs, then select the latest matching family run."""
+ selected = []
+ for course in courses:
+ key = course['key']
+ if filters['org'] and filters['org'].casefold() != key.org.casefold():
+ continue
+ if filters['run'] and filters['run'].casefold() != key.run.casefold():
+ continue
+ if filters['year'] and not key.run.startswith(filters['year']):
+ continue
+ searchable = f"{key} {course['name']} {course.get('faculty') or ''}".casefold()
+ if filters['q'] and filters['q'].casefold() not in searchable:
+ continue
+ selected.append(course)
+ if filters['latest']:
+ families = {}
+ for course in selected:
+ key = course['key']
+ family = (key.org.casefold(), key.course.casefold())
+ previous = families.get(family)
+ if previous is None or _run_order(course) > _run_order(previous):
+ families[family] = course
+ selected = list(families.values())
+ return sorted(selected, key=lambda course: (course['name'].casefold(), str(course['key'])))
+
+
+def _run_order(course):
+ """Compare C10 numerically; legacy runs fall back to their course start."""
+ match = RUN_PATTERN.fullmatch(course['key'].run)
+ start = course.get('start')
+ year = int(match[1]) if match else (start.year if start else 0)
+ sequence = int(match[2]) if match else 0
+ return year, sequence, str(start or ''), course['key'].run
+
+
+def _courses():
+ """Use Studio's inventory, including drafts absent from the SQL overview cache."""
+ overviews = {str(course.id): course for course in CourseOverview.objects.all()}
+ courses = []
+ for summary in modulestore().get_course_summaries():
+ key = summary.id
+ # Native reruns require split course keys; libraries/CCX are not courses to clone here.
+ if type(key) is not CourseLocator or key.deprecated: # pylint: disable=unidiomatic-typecheck
+ continue
+ overview = overviews.get(str(key))
+ courses.append({
+ 'key': key,
+ 'name': summary.display_name or str(key),
+ 'start': getattr(overview, 'start', None),
+ 'end': getattr(overview, 'end', None),
+ 'enrollment_start': getattr(overview, 'enrollment_start', None),
+ 'enrollment_end': getattr(overview, 'enrollment_end', None),
+ 'visibility': getattr(overview, 'catalog_visibility', None),
+ 'faculty': getattr(overview, 'faculty', ''),
+ 'self_paced': getattr(overview, 'self_paced', False),
+ 'invitation_only': getattr(overview, 'invitation_only', False),
+ 'certificate_available_date': getattr(overview, 'certificate_available_date', None),
+ })
+ return courses
+
+
+def _display_course(course, modes, generation):
+ row = dict(course)
+ key = row.pop('key')
+ row.update(id=str(key), org=key.org, run=key.run)
+ row['mode_labels'] = ', '.join(modes.get(str(key), [])) or f'{CourseMode.DEFAULT_MODE_SLUG} (по умолчанию)'
+ row['certificate_generation'] = 'Включена' if generation.get(str(key)) else 'Выключена'
+ row['self_paced_label'] = 'В своём темпе' if row.pop('self_paced') else 'По расписанию преподавателя'
+ row['invitation_only_label'] = 'Запись только по приглашению' if row.pop('invitation_only') else 'Открытая запись'
+ for field in ('start', 'end', 'enrollment_start', 'enrollment_end', 'certificate_available_date'):
+ value = row[field]
+ row[field] = value.astimezone(timezone.utc).strftime('%Y-%m-%d %H:%M') if value else ''
+ row['details_url'] = get_schedule_details_url(key) or reverse(
+ 'settings_handler', kwargs={'course_key_string': str(key)},
+ )
+ return row
+
+
+def _state_result(state):
+ return {
+ 'source_course_id': str(state.source_course_key),
+ 'course_id': str(state.course_key),
+ 'state': state.state,
+ 'message': ('Перезапуск завершён.' if state.state == 'succeeded' else
+ 'Ошибка перезапуска. Подробности доступны в журнале CMS worker.' if state.state == 'failed' else
+ 'Курс создаётся в фоновой задаче.'),
+ }
+
+
+@never_cache
+@admin_required
+@ensure_csrf_cookie
+@require_GET
+def course_admin_page(request):
+ """Render a direct-link-only management console, without adding any menu entry."""
+ try:
+ filters = parse_filters(request.GET)
+ except ValueError as exc:
+ return JsonResponse({'error': str(exc)}, status=400)
+ courses = filter_courses(_courses(), filters)
+ page = Paginator(courses, 50).get_page(request.GET.get('page', 1))
+ page_keys = [course['key'] for course in page.object_list]
+ modes = {}
+ for mode in CourseMode.objects.filter(course_id__in=page_keys).order_by('mode_slug', 'currency'):
+ modes.setdefault(str(mode.course_id), []).append(f'{mode.mode_slug} ({mode.currency})')
+ generation = get_self_generation_enabled_for_courses(page_keys)
+ recent = CourseRerunState.objects.find_all(created_user=request.user).order_by('-created_time')[:50]
+ return render_to_response('course-admin.html', {
+ 'courses': [_display_course(course, modes, generation) for course in page.object_list],
+ 'course_filters': filters,
+ 'page': page.number,
+ 'num_pages': page.paginator.num_pages,
+ 'total': page.paginator.count,
+ 'endpoint_url': reverse('course_admin_action'),
+ 'status_url': reverse('course_admin_status'),
+ 'initial_results': [_state_result(state) for state in recent],
+ 'timezone_label': 'UTC (Казахстан: UTC+5)',
+ 'csrf_token': get_token(request),
+ })
+
+
+def _selected_keys(payload):
+ all_matching = payload.get('all_matching', False)
+ if not isinstance(all_matching, bool):
+ raise ValueError('Некорректный способ выбора курсов.')
+ if all_matching:
+ keys = [course['key'] for course in filter_courses(_courses(), parse_filters(payload.get('filters', {})))]
+ else:
+ identifiers = payload.get('course_ids', [])
+ if not isinstance(identifiers, list) or not all(isinstance(value, str) for value in identifiers):
+ raise ValueError('Передайте список идентификаторов курсов.')
+ keys = []
+ for identifier in dict.fromkeys(identifiers):
+ key = CourseKey.from_string(identifier)
+ if (type(key) is not CourseLocator or key.deprecated or key.branch # pylint: disable=unidiomatic-typecheck
+ or key.version_guid):
+ raise ValueError('Поддерживаются только обычные курсы Studio с идентификатором course-v1.')
+ keys.append(key)
+ limit = getattr(settings, 'COURSE_ADMIN_MAX_BATCH_SIZE', 1000)
+ if not keys or len(keys) > limit:
+ raise ValueError(f'Выберите от 1 до {limit} курсов. Уточните фильтры для большой выборки.')
+ return keys
+
+
+@never_cache
+@admin_required
+@require_POST
+@csrf_protect
+def course_admin_action(request):
+ """Validate a complete batch before dispatch; report operational failures per course."""
+ try:
+ payload = json.loads(request.body)
+ if not isinstance(payload, dict):
+ raise ValueError('Ожидается JSON-объект.')
+ action = payload.get('action')
+ if action not in ('preview', 'rerun', 'save'):
+ raise ValueError('Неизвестное действие.')
+ raw_settings = payload.get('settings', {})
+ if not isinstance(raw_settings, dict):
+ raise ValueError('Некорректные настройки.')
+ normalized = service.validate_settings(raw_settings, require_dates=action != 'save')
+ keys = _selected_keys(payload)
+ if action == 'preview':
+ results = service.preview_course_reruns(request.user, keys, normalized)
+ targets = {row['source_course_id']: row['course_id'] for row in results if row['state'] == 'preview'}
+ token = signing.dumps({
+ 'user_id': request.user.pk, 'course_ids': [str(key) for key in keys],
+ 'settings': raw_settings, 'targets': targets,
+ }, salt=PREVIEW_SALT, compress=True)
+ return JsonResponse({'results': results, 'preview_token': token})
+ if action == 'rerun':
+ token = payload.get('preview_token')
+ if not isinstance(token, str):
+ raise ValueError('Сначала выполните предварительный просмотр перезапуска.')
+ preview = signing.loads(token, salt=PREVIEW_SALT, max_age=3600)
+ if (preview['user_id'] != request.user.pk or preview['settings'] != raw_settings
+ or set(preview['course_ids']) != {str(key) for key in keys}
+ or set(preview['targets']) != {str(key) for key in keys}):
+ raise ValueError('Выбор курсов или настройки изменились. Повторите предварительный просмотр.')
+ results = service.queue_course_reruns(request.user, keys, normalized,
+ expected_course_ids=preview['targets'])
+ else:
+ if not normalized:
+ raise ValueError('Выберите хотя бы одну настройку для сохранения.')
+ results = service.save_course_settings(request.user, keys, normalized)
+ LOG.info('Course admin action=%s user=%s courses=%s settings=%s results=%s', action, request.user.pk,
+ [str(key) for key in keys], normalized,
+ [(row['course_id'], row['state']) for row in results])
+ return JsonResponse({'results': results})
+ except (signing.BadSignature, KeyError):
+ return JsonResponse({
+ 'error': 'Предварительный просмотр истёк или недействителен. Выполните его снова.',
+ }, status=400)
+ except (ValueError, TypeError, InvalidKeyError, ValidationError) as exc:
+ message = '; '.join(exc.messages) if isinstance(exc, ValidationError) else str(exc)
+ return JsonResponse({'error': message}, status=400)
+
+
+@never_cache
+@admin_required
+@require_GET
+def course_admin_status(request):
+ """Fetch durable rerun results without relying on the Celery result backend."""
+ try:
+ identifiers = request.GET.get('course_ids', '').split(',')
+ if not 1 <= len(identifiers) <= 50:
+ raise ValueError('Запрашивайте от 1 до 50 статусов за один запрос.')
+ keys = [CourseKey.from_string(identifier) for identifier in identifiers]
+ except (ValueError, InvalidKeyError) as exc:
+ return JsonResponse({'error': str(exc)}, status=400)
+ states = {str(state.course_key): state for state in CourseRerunState.objects.find_all(course_key__in=keys)}
+ return JsonResponse({'results': [
+ _state_result(states[str(key)]) if str(key) in states else {
+ 'source_course_id': '', 'course_id': str(key), 'state': 'unknown',
+ 'message': 'Запись о перезапуске не найдена. Обновите страницу.',
+ } for key in keys
+ ]})
diff --git a/cms/djangoapps/contentstore/views/tests/test_course_admin.py b/cms/djangoapps/contentstore/views/tests/test_course_admin.py
new file mode 100644
index 000000000000..a711fc73f609
--- /dev/null
+++ b/cms/djangoapps/contentstore/views/tests/test_course_admin.py
@@ -0,0 +1,322 @@
+"""HTTP authorization, signed previews and selection rules for the private course console."""
+
+import json
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from django.conf import settings
+from django.core import signing
+from django.core.exceptions import ValidationError
+from django.http import HttpResponse
+from django.middleware.csrf import get_token
+from django.test import RequestFactory, SimpleTestCase, override_settings
+from opaque_keys.edx.locator import CourseLocator
+
+from cms.djangoapps.contentstore.views import course_admin as views
+
+
+SOURCE = 'course-v1:FBB+BNJjTSOB+2026_C1'
+TARGET = 'course-v1:FBB+BNJjTSOB+2026_C2'
+OTHER = 'course-v1:FBB+OTHER+2026_C1'
+DATES = {'start': '2026-09-01T00:00:00Z', 'end': '2026-12-31T00:00:00Z'}
+
+
+def course(run='2026_C1', org='FBB', number='BNJjTSOB', name='Biotechnology', start=None):
+ """Create an inventory row without a database or a Mongo course."""
+ return {
+ 'key': CourseLocator(org=org, course=number, run=run), 'name': name,
+ 'start': start, 'end': None, 'enrollment_start': None, 'enrollment_end': None,
+ 'faculty': 'Biology', 'visibility': 'both',
+ }
+
+
+@override_settings(COURSE_ADMIN_MAX_BATCH_SIZE=500)
+class CourseAdminViewTests(SimpleTestCase):
+ """Exercise real Django decorators, requests and signatures with service boundaries mocked."""
+
+ def setUp(self):
+ super().setUp()
+ self.factory = RequestFactory()
+ self.user = SimpleNamespace(pk=10, id=10, is_authenticated=True, is_active=True, is_staff=True)
+ self.source = CourseLocator.from_string(SOURCE)
+ self.preview_rows = [{
+ 'source_course_id': SOURCE, 'course_id': TARGET, 'state': 'preview', 'message': 'Ready',
+ }]
+ self.inventory = self.enterContext(patch.object(views, '_courses', return_value=[course()]))
+ self.validate = self.enterContext(patch.object(
+ views.service, 'validate_settings', side_effect=lambda values, **kwargs: dict(values),
+ ))
+ self.preview = self.enterContext(patch.object(
+ views.service, 'preview_course_reruns', return_value=self.preview_rows,
+ ))
+ self.queue = self.enterContext(patch.object(views.service, 'queue_course_reruns', return_value=[]))
+ self.save = self.enterContext(patch.object(views.service, 'save_course_settings', return_value=[]))
+ self.states = self.enterContext(patch.object(views.CourseRerunState, 'objects'))
+ self.modes = self.enterContext(patch.object(views.CourseMode, 'objects'))
+ self.modes.filter.return_value.order_by.return_value = []
+ self.generation = self.enterContext(patch.object(
+ views, 'get_self_generation_enabled_for_courses', return_value={},
+ ))
+
+ def request(self, method='post', payload=None, user=None, csrf=True, **extra):
+ """Build a request with a real, matched CSRF cookie and masked header token."""
+ if method == 'post':
+ request = self.factory.post('/course-admin/action/', json.dumps(payload),
+ content_type='application/json', **extra)
+ else:
+ request = getattr(self.factory, method)('/course-admin/', payload or {}, **extra)
+ request.user = user or self.user
+ if csrf:
+ token = get_token(request)
+ request.COOKIES[settings.CSRF_COOKIE_NAME] = request.META['CSRF_COOKIE']
+ request.META['HTTP_X_CSRFTOKEN'] = token
+ return request
+
+ def action(self, action='preview', **values):
+ payload = {'action': action, 'course_ids': [SOURCE], 'settings': dict(DATES)}
+ payload.update(values)
+ return views.course_admin_action(self.request(payload=payload))
+
+ @staticmethod
+ def body(response):
+ return json.loads(response.content)
+
+ def token(self, **changes):
+ data = {'user_id': self.user.pk, 'course_ids': [SOURCE], 'settings': dict(DATES),
+ 'targets': {SOURCE: TARGET}}
+ data.update(changes)
+ return signing.dumps(data, salt=views.PREVIEW_SALT)
+
+ def test_every_endpoint_rejects_anonymous_nonstaff_and_inactive_admins(self):
+ for endpoint in (views.course_admin_page, views.course_admin_action, views.course_admin_status):
+ for overrides in ({'is_authenticated': False}, {'is_staff': False}, {'is_active': False}):
+ with self.subTest(endpoint=endpoint.__name__, user=overrides):
+ user = SimpleNamespace(**(vars(self.user) | overrides))
+ response = endpoint(self.request(payload={}, user=user, csrf=False))
+ self.assertEqual(response.status_code, 403)
+ self.assertIn('no-store', response['Cache-Control'])
+ self.inventory.assert_not_called()
+ self.validate.assert_not_called()
+ self.preview.assert_not_called()
+ self.queue.assert_not_called()
+ self.save.assert_not_called()
+ self.states.find_all.assert_not_called()
+
+ def test_read_endpoints_reject_post_and_write_endpoint_rejects_get(self):
+ for endpoint in (views.course_admin_page, views.course_admin_status):
+ with self.subTest(endpoint=endpoint.__name__):
+ self.assertEqual(endpoint(self.request(payload={})).status_code, 405)
+ self.assertEqual(views.course_admin_action(self.request(method='get')).status_code, 405)
+ self.validate.assert_not_called()
+
+ def test_action_requires_csrf_for_each_operation(self):
+ for operation in ('preview', 'rerun', 'save'):
+ with self.subTest(operation=operation):
+ request = self.request(payload={'action': operation}, csrf=False)
+ self.assertEqual(views.course_admin_action(request).status_code, 403)
+ self.validate.assert_not_called()
+
+ def test_action_rejects_wrong_csrf_and_untrusted_origin(self):
+ request = self.request(payload={'action': 'save'})
+ request.META['HTTP_X_CSRFTOKEN'] = 'x' * 64
+ self.assertEqual(views.course_admin_action(request).status_code, 403)
+ request = self.request(payload={'action': 'save'}, HTTP_ORIGIN='https://untrusted.example')
+ self.assertEqual(views.course_admin_action(request).status_code, 403)
+ self.validate.assert_not_called()
+
+ def test_preview_is_read_only_and_signs_exact_selection_settings_and_targets(self):
+ response = self.action()
+ self.assertEqual(response.status_code, 200)
+ body = self.body(response)
+ self.assertEqual(body['results'], self.preview_rows)
+ decoded = signing.loads(body['preview_token'], salt=views.PREVIEW_SALT)
+ self.assertEqual(decoded, {'user_id': self.user.pk, 'course_ids': [SOURCE],
+ 'settings': DATES, 'targets': {SOURCE: TARGET}})
+ self.preview.assert_called_once_with(self.user, [self.source], DATES)
+ self.validate.assert_called_once_with(DATES, require_dates=True)
+ self.queue.assert_not_called()
+ self.save.assert_not_called()
+ self.assertIn('no-store', response['Cache-Control'])
+
+ def test_rerun_forwards_preview_mapping_and_repeated_token_unchanged(self):
+ token = self.body(self.action())['preview_token']
+ for _ in range(2):
+ self.assertEqual(self.action('rerun', preview_token=token).status_code, 200)
+ self.assertEqual(self.queue.call_count, 2)
+ for call in self.queue.call_args_list:
+ self.assertEqual(call.args, (self.user, [self.source], DATES))
+ self.assertEqual(call.kwargs, {'expected_course_ids': {SOURCE: TARGET}})
+ # The service owns reservation idempotency; the HTTP layer must not recompute a new target.
+ self.assertEqual(self.preview.call_count, 1)
+
+ def test_rerun_rejects_missing_corrupted_and_expired_tokens(self):
+ with patch('django.core.signing.time.time', return_value=1000):
+ expired = self.token()
+ for token in (None, '', 'corrupt', self.token() + 'x', expired):
+ with self.subTest(token=token):
+ self.assertEqual(self.action('rerun', preview_token=token).status_code, 400)
+ self.queue.assert_not_called()
+
+ def test_rerun_rejects_cross_user_changed_settings_changed_ids_and_partial_preview(self):
+ variants = (
+ {'user_id': 20}, {'settings': dict(DATES, catalog_visibility='none')},
+ {'course_ids': [OTHER]}, {'targets': {}}, {'targets': {SOURCE: TARGET, OTHER: OTHER}},
+ )
+ for changes in variants:
+ with self.subTest(changes=changes):
+ response = self.action('rerun', preview_token=self.token(**changes))
+ self.assertEqual(response.status_code, 400)
+ self.queue.assert_not_called()
+
+ def test_preview_with_one_failed_course_cannot_be_submitted_as_a_complete_batch(self):
+ self.preview.return_value = self.preview_rows + [{
+ 'source_course_id': OTHER, 'course_id': None, 'state': 'failed', 'message': 'Missing',
+ }]
+ ids = [SOURCE, OTHER]
+ response = self.action(course_ids=ids)
+ self.assertEqual(response.status_code, 200)
+ response = self.action('rerun', course_ids=ids, preview_token=self.body(response)['preview_token'])
+ self.assertEqual(response.status_code, 400)
+ self.queue.assert_not_called()
+
+ def test_explicit_selection_deduplicates_ids_and_preserves_order(self):
+ self.assertEqual(self.action(course_ids=[SOURCE, OTHER, SOURCE]).status_code, 200)
+ self.assertEqual(self.preview.call_args.args[1], [self.source, CourseLocator.from_string(OTHER)])
+
+ def test_all_matching_selects_server_inventory_beyond_one_display_page(self):
+ self.inventory.return_value = [course(number=f'course{index}') for index in range(60)]
+ response = self.action(all_matching=True, filters={'latest': False}, course_ids=[OTHER])
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(self.preview.call_args.args[1]), 60)
+ self.assertNotIn(CourseLocator.from_string(OTHER), self.preview.call_args.args[1])
+
+ def test_changed_all_matching_inventory_invalidates_preview(self):
+ token = self.body(self.action(all_matching=True, filters={}))['preview_token']
+ self.inventory.return_value.append(course(number='NEW'))
+ response = self.action('rerun', all_matching=True, filters={}, preview_token=token)
+ self.assertEqual(response.status_code, 400)
+ self.queue.assert_not_called()
+
+ @override_settings(COURSE_ADMIN_MAX_BATCH_SIZE=1)
+ def test_empty_and_oversized_selections_rejected_before_service_action(self):
+ for identifiers in ([], [SOURCE, OTHER]):
+ with self.subTest(identifiers=identifiers):
+ self.assertEqual(self.action(course_ids=identifiers).status_code, 400)
+ self.inventory.return_value = [course(), course(number='OTHER')]
+ self.assertEqual(self.action(all_matching=True).status_code, 400)
+ self.preview.assert_not_called()
+
+ def test_malformed_payload_settings_filters_and_identifiers_rejected(self):
+ variants = (
+ {'action': 'delete'}, {'settings': []}, {'course_ids': SOURCE}, {'course_ids': [1]},
+ {'course_ids': ['not-a-course']}, {'course_ids': ['FBB/BNJjTSOB/2026_C1']},
+ {'course_ids': ['library-v1:FBB+LIB']}, {'all_matching': 'true'},
+ {'all_matching': True, 'filters': []}, {'all_matching': True, 'filters': {'year': '26'}},
+ )
+ for values in variants:
+ with self.subTest(values=values):
+ self.assertEqual(self.action(**values).status_code, 400)
+ request = self.factory.post('/course-admin/action/', '{', content_type='application/json')
+ request.user = self.user
+ request._dont_enforce_csrf_checks = True # Test JSON decoding independently of CSRF tests above.
+ self.assertEqual(views.course_admin_action(request).status_code, 400)
+ self.assertEqual(views.course_admin_action(self.request(payload=[])).status_code, 400)
+ self.preview.assert_not_called()
+ self.queue.assert_not_called()
+ self.save.assert_not_called()
+
+ def test_service_settings_validation_errors_return_400_without_mutation(self):
+ self.validate.side_effect = ValidationError(['Invalid start date', 'Invalid visibility'])
+ response = self.action('save', settings={'start': 'bad', 'catalog_visibility': 'public'})
+ self.assertEqual(response.status_code, 400)
+ self.assertIn('Invalid start date', self.body(response)['error'])
+ self.save.assert_not_called()
+
+ def test_save_accepts_partial_settings_without_preview_and_rejects_empty_settings(self):
+ response = self.action('save', settings={'catalog_visibility': 'none'})
+ self.assertEqual(response.status_code, 200)
+ self.validate.assert_called_with({'catalog_visibility': 'none'}, require_dates=False)
+ self.save.assert_called_once_with(self.user, [self.source], {'catalog_visibility': 'none'})
+ self.assertEqual(self.action('save', settings={}).status_code, 400)
+ self.assertEqual(self.save.call_count, 1)
+ self.queue.assert_not_called()
+
+ def test_page_paginates_and_sets_csrf_cookie(self):
+ self.inventory.return_value = [course(number=f'course{index}') for index in range(60)]
+ self.states.find_all.return_value.order_by.return_value = []
+ with patch.object(views, 'render_to_response', return_value=HttpResponse('console')) as render, \
+ patch.object(views, '_display_course', side_effect=lambda row, modes, generation: {
+ 'id': str(row['key']),
+ }), \
+ patch.object(views, 'reverse', return_value='/endpoint/'):
+ response = views.course_admin_page(self.request(method='get', payload={'page': '2'}))
+ self.assertEqual(response.status_code, 200)
+ context = render.call_args.args[1]
+ self.assertEqual((context['total'], context['num_pages'], context['page']), (60, 2, 2))
+ self.assertEqual(len(context['courses']), 10)
+ self.assertIn(settings.CSRF_COOKIE_NAME, response.cookies)
+ self.assertTrue(context['csrf_token'])
+
+ def test_page_rejects_invalid_filters_before_inventory_read(self):
+ response = views.course_admin_page(self.request(method='get', payload={'year': 'bad'}))
+ self.assertEqual(response.status_code, 400)
+ self.inventory.assert_not_called()
+
+ def test_status_returns_persisted_state_and_unknown_without_task_backend(self):
+ state = SimpleNamespace(source_course_key=self.source, course_key=CourseLocator.from_string(TARGET),
+ state='failed')
+ self.states.find_all.return_value = [state]
+ response = views.course_admin_status(self.request(
+ method='get', payload={'course_ids': ','.join((TARGET, OTHER))},
+ ))
+ self.assertEqual(response.status_code, 200)
+ rows = self.body(response)['results']
+ self.assertEqual([(row['course_id'], row['state']) for row in rows], [(TARGET, 'failed'), (OTHER, 'unknown')])
+ self.queue.assert_not_called()
+ self.states.find_all.assert_called_once_with(
+ course_key__in=[CourseLocator.from_string(TARGET), CourseLocator.from_string(OTHER)],
+ )
+
+ def test_status_rejects_missing_invalid_and_oversized_ids(self):
+ for value in ('', 'invalid', ','.join([SOURCE] * 51)):
+ with self.subTest(value=value):
+ response = views.course_admin_status(self.request(method='get', payload={'course_ids': value}))
+ self.assertEqual(response.status_code, 400)
+ self.states.find_all.assert_not_called()
+
+
+class CourseAdminFilterTests(SimpleTestCase):
+ """Selection preserves course families and supports historical run identifiers."""
+
+ def test_latest_compares_numeric_run_and_keeps_organizations_separate(self):
+ rows = [course('2026_C2'), course('2026_C10'), course('2025_C20'), course('2026_C1', org='kaznu')]
+ result = views.filter_courses(rows, views.parse_filters({}))
+ self.assertEqual({str(row['key']) for row in result}, {
+ 'course-v1:FBB+BNJjTSOB+2026_C10', 'course-v1:kaznu+BNJjTSOB+2026_C1',
+ })
+
+ def test_legacy_runs_remain_selectable_and_use_start_for_latest(self):
+ rows = [course('C3', start=datetime(2021, 10, 1, tzinfo=timezone.utc)),
+ course('2022-2023_C1', start=datetime(2022, 11, 7, tzinfo=timezone.utc)),
+ course('2021C2', start=datetime(2021, 1, 25, tzinfo=timezone.utc))]
+ selected = views.filter_courses(rows, views.parse_filters({}))
+ self.assertEqual(selected[0]['key'].run, '2022-2023_C1')
+ selected = views.filter_courses(rows, views.parse_filters({'run': 'C3'}))
+ self.assertEqual(selected[0]['key'].run, 'C3')
+ self.assertEqual(len(views.filter_courses(rows, views.parse_filters({'latest': False}))), 3)
+
+ def test_filters_apply_before_latest_and_match_literals_case_insensitively(self):
+ rows = [course('2025_C1'), course('2026_C1'), course('2025_C2', org='OTHER')]
+ selected = views.filter_courses(rows, views.parse_filters({'year': '2025', 'org': 'fbb', 'q': 'biology'}))
+ self.assertEqual([str(row['key']) for row in selected], ['course-v1:FBB+BNJjTSOB+2025_C1'])
+ self.assertEqual(views.filter_courses(rows, views.parse_filters({'q': '.*'})), [])
+
+ def test_filter_validation_bounds_and_normalization(self):
+ for values in ([], {'q': ['x']}, {'q': 'x' * 256}, {'org': None}, {'year': '0000'},
+ {'year': '20266'}, {'latest': 'yes'}):
+ with self.subTest(values=values), self.assertRaises(ValueError):
+ views.parse_filters(values)
+ normalized = views.parse_filters({'q': ' Biology ', 'latest': 'false'})
+ self.assertEqual(normalized['q'], 'Biology')
+ self.assertFalse(normalized['latest'])
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 65425f566597..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'
diff --git a/cms/static/js/course_admin.js b/cms/static/js/course_admin.js
new file mode 100644
index 000000000000..20a22b79eb3f
--- /dev/null
+++ b/cms/static/js/course_admin.js
@@ -0,0 +1,373 @@
+/* global fetch */
+(function() {
+ 'use strict';
+
+ var root = document.getElementById('course-admin');
+ if (!root) {
+ return;
+ }
+
+ var settingsForm = document.getElementById('course-settings');
+ var courseCheckboxes = Array.from(root.querySelectorAll('input[name="course_ids"]'));
+ var selectPage = document.getElementById('select-page');
+ var allMatching = document.getElementById('all-matching');
+ var previewButton = document.getElementById('preview-rerun');
+ var rerunButton = document.getElementById('rerun-courses');
+ var saveButton = document.getElementById('save-settings');
+ var actionMessage = document.getElementById('action-message');
+ var pollMessage = document.getElementById('poll-message');
+ var resultBody = document.getElementById('operation-results');
+ var results = new Map();
+ var appliedFilters = JSON.parse(root.dataset.filters);
+ var total = Number(root.dataset.total);
+ var busy = false;
+ var previewSignature = null;
+ var previewTargets = null;
+ var previewToken = null;
+ var pollTimer = null;
+ var polling = false;
+ var pollDelay = 3000;
+ var stateLabels = {
+ preview: 'Проверено',
+ queued: 'В очереди',
+ in_progress: 'Создаётся',
+ succeeded: 'Готово',
+ failed: 'Ошибка',
+ saved: 'Сохранено'
+ };
+
+ function selectedIds() {
+ return courseCheckboxes.filter(function(input) { return input.checked; })
+ .map(function(input) { return input.value; });
+ }
+
+ function selectedCount() {
+ return allMatching.checked ? total : selectedIds().length;
+ }
+
+ function notify(message, kind) {
+ actionMessage.textContent = message;
+ actionMessage.className = 'notice' + (kind ? ' ' + kind : '');
+ actionMessage.hidden = !message;
+ }
+
+ function updateControls() {
+ var count = selectedCount();
+ var selectedOnPage = selectedIds().length;
+ document.getElementById('selection-count').textContent = 'Выбрано: ' + count;
+ selectPage.checked = allMatching.checked || (courseCheckboxes.length > 0 && selectedOnPage === courseCheckboxes.length);
+ selectPage.indeterminate = !allMatching.checked && selectedOnPage > 0 && selectedOnPage < courseCheckboxes.length;
+ selectPage.disabled = busy || allMatching.checked || !courseCheckboxes.length;
+ allMatching.disabled = busy || !total;
+ courseCheckboxes.forEach(function(input) { input.disabled = busy || allMatching.checked; });
+ previewButton.disabled = busy || !count;
+ saveButton.disabled = busy || !count;
+ rerunButton.disabled = busy || !count || !previewSignature;
+ Array.from(settingsForm.elements).forEach(function(input) {
+ if (input.tagName !== 'BUTTON' && input.type !== 'hidden') {
+ var clearInput = settingsForm.elements.namedItem('clear_' + input.name);
+ input.disabled = busy || Boolean(clearInput && clearInput.checked);
+ }
+ });
+ root.setAttribute('aria-busy', busy ? 'true' : 'false');
+ }
+
+ function invalidatePreview() {
+ previewSignature = null;
+ previewTargets = null;
+ previewToken = null;
+ document.getElementById('preview-hint').textContent = 'Сначала выберите курсы и проверьте новые ID. После изменения настроек проверку нужно повторить.';
+ updateControls();
+ }
+
+ function collectPayload(action) {
+ var settings = {};
+ ['start', 'end', 'enrollment_start', 'enrollment_end', 'certificate_available_date'].forEach(function(name) {
+ var input = settingsForm.elements.namedItem(name);
+ var clearInput = settingsForm.elements.namedItem('clear_' + name);
+ if (clearInput && clearInput.checked) {
+ settings[name] = null;
+ } else if (input.value) {
+ // datetime-local has no zone. This form explicitly collects UTC, independent of the browser zone.
+ var date = new Date(input.value + 'Z');
+ if (Number.isNaN(date.getTime())) {
+ throw new Error('Проверьте дату: ' + name + '.');
+ }
+ settings[name] = date.toISOString();
+ }
+ });
+ ['catalog_visibility', 'mode', 'certificate_mode', 'self_paced', 'invitation_only'].forEach(function(name) {
+ var value = settingsForm.elements.namedItem(name).value;
+ if (value !== '') {
+ settings[name] = name === 'self_paced' || name === 'invitation_only' ? value === 'true' : value;
+ }
+ });
+ if (action !== 'save') {
+ if (!settings.start || !settings.end) {
+ throw new Error('Для нового запуска заполните начало и конец курса в UTC.');
+ }
+ settings.mode = settings.mode || 'copy';
+ settings.certificate_mode = settings.certificate_mode || 'copy';
+ settings.shift_content_dates = settingsForm.elements.namedItem('shift_content_dates').checked;
+ } else if (!Object.keys(settings).length) {
+ throw new Error('Заполните хотя бы одно поле или выберите настройку для изменения.');
+ }
+ if (settings.start && settings.end && settings.start >= settings.end) {
+ throw new Error('Конец курса должен быть позже начала.');
+ }
+ if (settings.enrollment_start && settings.enrollment_end && settings.enrollment_start >= settings.enrollment_end) {
+ throw new Error('Конец записи должен быть позже начала записи.');
+ }
+ if (!selectedCount()) {
+ throw new Error('Выберите хотя бы один курс.');
+ }
+ return {
+ action: action,
+ course_ids: allMatching.checked ? [] : selectedIds(),
+ all_matching: allMatching.checked,
+ filters: appliedFilters,
+ settings: settings
+ };
+ }
+
+ function signature(payload) {
+ return JSON.stringify({
+ course_ids: payload.course_ids,
+ all_matching: payload.all_matching,
+ filters: payload.filters,
+ settings: payload.settings
+ });
+ }
+
+ async function readResponse(response) {
+ var body;
+ try {
+ body = await response.json();
+ } catch (error) {
+ throw new Error('Сервер вернул неожиданный ответ. Проверьте авторизацию и обновите страницу.');
+ }
+ if (!response.ok) {
+ var detail = body.error || body.message;
+ if (typeof detail !== 'string') {
+ detail = 'Запрос не выполнен (HTTP ' + response.status + ').';
+ }
+ var requestError = new Error(detail);
+ requestError.status = response.status;
+ requestError.results = body.results;
+ throw requestError;
+ }
+ if (!Array.isArray(body.results)) {
+ throw new Error('В ответе сервера отсутствует список результатов. Обновите страницу для проверки операций.');
+ }
+ return body;
+ }
+
+ function renderResults() {
+ resultBody.replaceChildren();
+ var counts = {};
+ results.forEach(function(result) {
+ var row = document.createElement('tr');
+ [result.source_course_id, result.course_id].forEach(function(id) {
+ var cell = document.createElement('td');
+ cell.className = 'result-id';
+ var code = document.createElement('code');
+ code.textContent = id || '—';
+ cell.appendChild(code);
+ row.appendChild(cell);
+ });
+ var stateCell = document.createElement('td');
+ var state = document.createElement('span');
+ var knownState = Object.prototype.hasOwnProperty.call(stateLabels, result.state);
+ state.className = 'status' + (knownState ? ' status-' + result.state : '');
+ state.textContent = knownState ? stateLabels[result.state] : (result.state || 'Неизвестно');
+ stateCell.appendChild(state);
+ row.appendChild(stateCell);
+ var message = document.createElement('td');
+ message.className = 'result-message';
+ message.textContent = result.message || '';
+ row.appendChild(message);
+ resultBody.appendChild(row);
+ counts[result.state] = (counts[result.state] || 0) + 1;
+ });
+ document.getElementById('results-panel').hidden = !results.size;
+ document.getElementById('results-summary').textContent = Object.keys(stateLabels)
+ .filter(function(state) { return counts[state]; })
+ .map(function(state) { return stateLabels[state] + ': ' + counts[state]; }).join(' · ');
+ }
+
+ function mergeResults(newResults, isPreview) {
+ if (isPreview) {
+ results.forEach(function(result, key) {
+ if (result.state === 'preview') {
+ results.delete(key);
+ }
+ });
+ }
+ (newResults || []).forEach(function(result) {
+ // A status lookup may return an unknown destination without its source.
+ // Reuse the source from the pending row so the update replaces it and
+ // polling does not continue forever under a second map key.
+ if (!result.source_course_id && result.course_id) {
+ results.forEach(function(existing) {
+ if (!result.source_course_id && existing.course_id === result.course_id) {
+ result = Object.assign({}, result, {source_course_id: existing.source_course_id});
+ }
+ });
+ }
+ if (!Object.prototype.hasOwnProperty.call(stateLabels, result.state)) {
+ result = Object.assign({}, result, {
+ state: 'failed',
+ message: result.message || 'Состояние операции недоступно. Проверьте курс в Studio.'
+ });
+ }
+ var key = (result.source_course_id || '') + '→' + (result.course_id || '');
+ results.set(key, result);
+ });
+ renderResults();
+ }
+
+ function pendingIds() {
+ var ids = [];
+ results.forEach(function(result) {
+ if ((result.state === 'queued' || result.state === 'in_progress') && result.course_id) {
+ ids.push(result.course_id);
+ }
+ });
+ return Array.from(new Set(ids));
+ }
+
+ function schedulePoll() {
+ if (!pollTimer && !polling && pendingIds().length) {
+ pollTimer = window.setTimeout(poll, pollDelay);
+ }
+ }
+
+ async function poll() {
+ pollTimer = null;
+ var ids = pendingIds();
+ if (!ids.length) {
+ return;
+ }
+ polling = true;
+ try {
+ // Keep status URLs bounded when the administrator selected all matching courses.
+ for (var offset = 0; offset < ids.length; offset += 20) {
+ var url = new URL(root.dataset.statusUrl, window.location.href);
+ url.searchParams.set('course_ids', ids.slice(offset, offset + 20).join(','));
+ var response = await fetch(url.toString(), {
+ credentials: 'same-origin',
+ cache: 'no-store',
+ headers: {Accept: 'application/json'}
+ });
+ var body = await readResponse(response);
+ mergeResults(body.results, false);
+ }
+ pollMessage.hidden = true;
+ pollDelay = 3000;
+ } catch (error) {
+ pollMessage.textContent = 'Не удалось обновить состояние: ' + error.message + ' Проверка будет повторена автоматически.';
+ pollMessage.hidden = false;
+ pollDelay = Math.min(pollDelay * 2, 30000);
+ } finally {
+ polling = false;
+ schedulePoll();
+ }
+ }
+
+ async function submitAction(action) {
+ if (busy || !settingsForm.reportValidity()) {
+ return;
+ }
+ var payload;
+ try {
+ payload = collectPayload(action);
+ if (action === 'rerun' && previewSignature !== signature(payload)) {
+ throw new Error('Настройки изменились. Сначала повторите проверку новых запусков.');
+ }
+ if (action === 'rerun') {
+ payload.preview_targets = previewTargets;
+ payload.preview_token = previewToken;
+ }
+ } catch (error) {
+ notify(error.message, 'error');
+ return;
+ }
+ busy = true;
+ updateControls();
+ notify(action === 'preview' ? 'Проверяем курсы и новые номера запусков…' : 'Отправляем операцию. Дождитесь ответа сервера…');
+ try {
+ var response = await fetch(root.dataset.endpoint, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ 'X-CSRFToken': settingsForm.elements.namedItem('csrfmiddlewaretoken').value
+ },
+ body: JSON.stringify(payload)
+ });
+ var body = await readResponse(response);
+ mergeResults(body.results, action === 'preview');
+ var failed = body.results.filter(function(result) { return result.state === 'failed'; }).length;
+ if (action === 'preview') {
+ var previewReady = Boolean(body.preview_token) && body.results.length > 0 && body.results.every(function(result) {
+ return result.state === 'preview' && result.source_course_id && result.course_id;
+ });
+ previewSignature = previewReady ? signature(payload) : null;
+ previewToken = previewReady ? body.preview_token : null;
+ previewTargets = previewReady ? {} : null;
+ if (previewReady) {
+ body.results.forEach(function(result) { previewTargets[result.source_course_id] = result.course_id; });
+ }
+ document.getElementById('preview-hint').textContent = previewReady
+ ? 'Проверено запусков: ' + body.results.length + '. Новые ID показаны ниже. Нажмите «Создать новые запуски», чтобы начать.'
+ : 'Проверьте сообщения по каждому курсу и повторите проверку после исправления ошибок.';
+ notify(previewReady ? 'Предварительная проверка завершена. Новые курсы ещё не созданы.' : 'Не все курсы прошли проверку. Подробности ниже.', previewReady ? 'success' : 'error');
+ } else {
+ invalidatePreview();
+ notify(failed
+ ? 'Операция завершилась с ошибками для ' + failed + ' курсов. Проверьте результаты ниже.'
+ : (action === 'rerun' ? 'Запрос принят. Следите за состоянием создания курсов ниже.' : 'Настройки сохранены. Результаты по каждому курсу показаны ниже.'), failed ? 'error' : 'success');
+ }
+ schedulePoll();
+ } catch (error) {
+ var safeRerunRetry = action === 'rerun' && (!error.status || error.status >= 500);
+ if (!safeRerunRetry) {
+ invalidatePreview();
+ }
+ if (Array.isArray(error.results)) {
+ mergeResults(error.results, action === 'preview');
+ schedulePoll();
+ }
+ var message = error.message;
+ if (safeRerunRetry) {
+ message += ' Можно повторить создание: сохранены те же ID и параметры, чтобы не создать дубликаты.';
+ } else if (action !== 'preview') {
+ message += ' Если запрос успел выполниться, изменения уже применены. Перед повтором обновите страницу и проверьте результаты.';
+ }
+ notify(message, 'error');
+ } finally {
+ busy = false;
+ updateControls();
+ }
+ }
+
+ selectPage.addEventListener('change', function() {
+ courseCheckboxes.forEach(function(input) { input.checked = selectPage.checked; });
+ invalidatePreview();
+ });
+ allMatching.addEventListener('change', invalidatePreview);
+ courseCheckboxes.forEach(function(input) { input.addEventListener('change', invalidatePreview); });
+ settingsForm.addEventListener('input', invalidatePreview);
+ settingsForm.addEventListener('change', invalidatePreview);
+ document.getElementById('course-filters').addEventListener('input', invalidatePreview);
+ document.getElementById('course-filters').addEventListener('change', invalidatePreview);
+ settingsForm.addEventListener('submit', function(event) { event.preventDefault(); });
+ previewButton.addEventListener('click', function() { submitAction('preview'); });
+ rerunButton.addEventListener('click', function() { submitAction('rerun'); });
+ saveButton.addEventListener('click', function() { submitAction('save'); });
+
+ mergeResults(JSON.parse(root.dataset.initialResults), false);
+ updateControls();
+ schedulePoll();
+}());
diff --git a/cms/templates/course-admin.html b/cms/templates/course-admin.html
new file mode 100644
index 000000000000..2c0234386020
--- /dev/null
+++ b/cms/templates/course-admin.html
@@ -0,0 +1,228 @@
+## coding=utf-8
+<%page expression_filter="h"/>
+<%namespace name="static" file="static_content.html"/>
+<%!
+import json
+from urllib.parse import urlencode
+%>
+<%
+ pagination_filters = {key: value for key, value in course_filters.items() if value not in ('', None, False)}
+ pagination_filters['latest'] = '1' if course_filters.get('latest') else '0'
+ visibility_labels = {'both': 'Каталог и страница курса', 'about': 'Только страница курса', 'none': 'Скрыт'}
+%>
+
+
+
+
+
+
+ Управление курсами — Studio
+
+
+
+
+
+
+
Studio · Только для администраторов
+
Управление курсами
+
Выберите курсы, проверьте новые номера запусков и создайте их с общими настройками.
+ Здесь же можно изменить даты, видимость, режимы обучения и сертификаты существующих курсов.
+
+
+
+
+
1. Выберите курсы
Найдено: ${total}
+
+
+
+
+ Выбрано: 0
+
+
+
+
Курсы, соответствующие применённым фильтрам
+
Выбор
Курс
Организация / запуск
Обучение (${timezone_label})
Запись (${timezone_label})
Видимость
+
+ % for course in courses:
+
+
+
${course['name']}${course['id']}
+ % if course.get('mode_labels'):
+
Режимы: ${course['mode_labels']}
+ % endif
+ % if course.get('certificate_generation'):
+
+ % 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')
+ %>
+
+
${_("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')
+ %>
+
+
${_("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')
+ %>
+
${_("Explore our collection of educational programs and start learning today")}
+
+
+
+
+
+
${_("Find your next course")}
+
+
+
+
+
+
+
+
+
+
+
+
+ % 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')
+ %>
+
+
${_("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')
+ %>
+