diff --git a/cms/djangoapps/contentstore/admin.py b/cms/djangoapps/contentstore/admin.py index cad62fadc8..2578e7368c 100644 --- a/cms/djangoapps/contentstore/admin.py +++ b/cms/djangoapps/contentstore/admin.py @@ -2,10 +2,111 @@ Admin site bindings for contentstore """ +import logging from config_models.admin import ConfigurationModelAdmin from django.contrib import admin +from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME +from django.utils.translation import ugettext as _ from cms.djangoapps.contentstore.models import VideoUploadConfig +from cms.djangoapps.contentstore.outlines_regenerate import CourseOutlineRegenerate +from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines + +from .tasks import update_outline_from_modulestore_task, update_all_outlines_from_modulestore_task + + +log = logging.getLogger(__name__) + + +class ReadOnlyAdminMixin(object): + """ + Disables all editing capabilities for the admin's model. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.list_display_links = None + self.readonly_fields = [f.name for f in self.model._meta.get_fields()] + + def get_actions(self, request): + actions = super().get_actions(request) + if 'delete_selected' in actions: + del actions["delete_selected"] + return actions + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): # pylint: disable=unused-argument + return False + + def save_model(self, request, obj, form, change): + pass + + def delete_model(self, request, obj): + pass + + def save_related(self, request, form, formsets, change): + pass + + +def regenerate_course_outlines_subset(modeladmin, request, queryset): + """ + Create a celery task to regenerate a single course outline for each passed-in course key. + + If the number of passed-in course keys is above a threshold, then instead create a celery task which + will then create a celery task to regenerate a single course outline for each passed-in course key. + """ + all_course_keys_qs = queryset.values_list('id', flat=True) + + # Create a separate celery task for each course outline requested. + regenerates = 0 + for course_key in all_course_keys_qs: + if key_supports_outlines(course_key): + log.info("Queuing outline creation for %s", course_key) + update_outline_from_modulestore_task.delay(str(course_key)) + regenerates += 1 + else: + log.info("Outlines not supported for %s - skipping", course_key) + msg = _("Number of course outline regenerations successfully requested: {regenerates}").format( + regenerates=regenerates + ) + modeladmin.message_user(request, msg) +regenerate_course_outlines_subset.short_description = _("Regenerate selected course outlines") + + +def regenerate_course_outlines_all(modeladmin, request, queryset): # pylint: disable=unused-argument + """ + Custom admin action which regenerates *all* the course outlines - no matter which CourseOverviews are selected. + """ + update_all_outlines_from_modulestore_task.delay() + modeladmin.message_user(request, _("All course outline regenerations successfully requested.")) +regenerate_course_outlines_all.short_description = _("Regenerate *all* course outlines") + + +class CourseOutlineRegenerateAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): + """ + Regenerates the course outline for each selected course key. + """ + list_display = ['id'] + ordering = ['id'] + search_fields = ['id'] + + actions = [regenerate_course_outlines_subset, regenerate_course_outlines_all] + + def changelist_view(self, request, extra_context=None): + """ + Overrides the admin's changelist_view & selects at least one of the CourseOverviews + when the custom regenerate_course_outlines_all action is selected. + """ + if 'action' in request.POST and request.POST['action'] == 'regenerate_course_outlines_all': + # Slight hack: Ensure that at least one CourseOverview course key is selected. + # The selection will be ignored, but the action will fail if *nothing* is selected. + post = request.POST.copy() + post.setlist(ACTION_CHECKBOX_NAME, self.model.get_course_outline_ids()[:1]) + request._set_post(post) # pylint: disable=protected-access + return super().changelist_view(request, extra_context) + admin.site.register(VideoUploadConfig, ConfigurationModelAdmin) +admin.site.register(CourseOutlineRegenerate, CourseOutlineRegenerateAdmin) diff --git a/cms/djangoapps/contentstore/migrations/0006_courseoutlineregenerate.py b/cms/djangoapps/contentstore/migrations/0006_courseoutlineregenerate.py new file mode 100644 index 0000000000..0ce69ce96b --- /dev/null +++ b/cms/djangoapps/contentstore/migrations/0006_courseoutlineregenerate.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.20 on 2021-05-27 17:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_overviews', '0024_overview_adds_has_highlights'), + ('contentstore', '0005_add_enable_checklists_quality_waffle_flag'), + ] + + operations = [ + migrations.CreateModel( + name='CourseOutlineRegenerate', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('course_overviews.courseoverview',), + ), + ] diff --git a/cms/djangoapps/contentstore/outlines_regenerate.py b/cms/djangoapps/contentstore/outlines_regenerate.py new file mode 100644 index 0000000000..5665b692c5 --- /dev/null +++ b/cms/djangoapps/contentstore/outlines_regenerate.py @@ -0,0 +1,28 @@ +""" +Defines a proxy model to enable a Django admin interface to trigger asynch +tasks which regenerates course outline data. +""" +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + +class CourseOutlineRegenerate(CourseOverview): + """ + Proxy model for CourseOverview. + + Does *not* create/update/delete CourseOverview objects - only reads the objects. + Uses the course IDs of the CourseOverview objects to determine which course + outlines to regenerate. + """ + class Meta: + proxy = True + + def __str__(self): + """Represent ourselves with the course key.""" + return str(self.id) + + @classmethod + def get_course_outline_ids(cls): + """ + Returns all the CourseOverview object ids. + """ + return cls.objects.values_list('id', flat=True) diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index db74e2d53c..82ae009702 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -65,6 +65,7 @@ from xmodule.modulestore.xml_importer import import_course_from_xml, import_libr from .exceptions import CourseImportException from .outlines import update_outline_from_modulestore +from .outlines_regenerate import CourseOutlineRegenerate from .toggles import bypass_olx_failure_enabled from .utils import course_import_olx_validation_is_enabled @@ -668,7 +669,34 @@ def import_olx(self, user_id, course_key_string, archive_path, archive_name, lan @shared_task @set_code_owner_attribute -def update_outline_from_modulestore_task(course_key_str): +def update_all_outlines_from_modulestore_task(): + """ + Celery task that creates multiple celery tasks - one per learning_sequence course outline + to regenerate. The list of course keys to regenerate comes from the proxy model itself. + """ + course_key_list = [str(course_key) for course_key in CourseOutlineRegenerate.get_course_outline_ids()] + for course_key_str in course_key_list: + try: + course_key = CourseKey.from_string(course_key_str) + if not key_supports_outlines(course_key): + LOGGER.warning( + ( + "update_multiple_outlines_from_modulestore_task called for course key" + " %s, which does not support learning_sequence outlines." + ), + course_key_str + ) + return + + update_outline_from_modulestore_task.delay(course_key_str) + except Exception: # pylint: disable=broad-except + # Swallow the exception to continue the loop through course keys - but log it. + LOGGER.exception("Could not create course outline for course %s", course_key_str) + + +@shared_task +@set_code_owner_attribute +def update_outline_from_modulestore_task(course_key_str: str): """ Celery task that creates a learning_sequence course outline. """ @@ -685,7 +713,7 @@ def update_outline_from_modulestore_task(course_key_str): return update_outline_from_modulestore(course_key) - except Exception: # pylint disable=broad-except + except Exception: # pylint: disable=broad-except LOGGER.exception("Could not create course outline for course %s", course_key_str) raise # Re-raise so that errors are noted in reporting.