From f5b74fcf312afcf243e582cdeab5062df83a3afc Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 4 Mar 2021 11:58:00 -0500 Subject: [PATCH] feat: Backfill and Django Admin for Learning Sequence Outline * Adds the backfill_course_outlines management command to contentstore * Adds a read-only Django admin interface to learning_sequences for the support team and debugging. * Adds two new functions to the learning_sequences public API: key_supports_outlines and get_course_keys_with_outlines The learning_sequences app isn't supposed to know about contentstore or modulestore, as it's intended to be extracted out of edx-platform in the long term. Therefore, the backfill_course_outlines command is in contentstore, and not learning_sequences. This work was tracked in TNL-7983, but it also fixes a bug where we were trying to generate course outlines for libraries (TNL-7981). All Open edX instances upgrading to Lilac should run the backfill_course_outlines command as part of their upgrade process. --- .../commands/backfill_course_outlines.py | 66 ++++++++++ .../tests/test_backfill_course_outlines.py | 117 ++++++++++++++++++ .../contentstore/signals/handlers.py | 4 +- cms/djangoapps/contentstore/tasks.py | 11 ++ .../content/learning_sequences/admin.py | 96 ++++++++++++++ .../learning_sequences/api/__init__.py | 2 + .../learning_sequences/api/outlines.py | 34 ++++- .../api/tests/test_outlines.py | 25 +++- .../content/learning_sequences/apps.py | 2 +- .../0008_add_learning_context_title_index.py | 18 +++ .../content/learning_sequences/models.py | 7 +- .../content/learning_sequences/views.py | 4 +- 12 files changed, 374 insertions(+), 12 deletions(-) create mode 100644 cms/djangoapps/contentstore/management/commands/backfill_course_outlines.py create mode 100644 cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_outlines.py create mode 100644 openedx/core/djangoapps/content/learning_sequences/admin.py create mode 100644 openedx/core/djangoapps/content/learning_sequences/migrations/0008_add_learning_context_title_index.py diff --git a/cms/djangoapps/contentstore/management/commands/backfill_course_outlines.py b/cms/djangoapps/contentstore/management/commands/backfill_course_outlines.py new file mode 100644 index 0000000000..17737cafb6 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/backfill_course_outlines.py @@ -0,0 +1,66 @@ +""" +Management command to create the course outline for all courses that are missing +an outline. Outlines are built automatically on course publish and manually +using the `update_course_outline` command, but they can be backfilled using this +command. People updating to Lilac release should run this command as part of the +upgrade process. + +This should be invoked from the Studio process. +""" +import logging + +from django.core.management.base import BaseCommand + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.content.learning_sequences.api import ( + get_course_keys_with_outlines, + key_supports_outlines, +) + +from ...tasks import update_outline_from_modulestore_task + +log = logging.getLogger('backfill_course_outlines') + + +class Command(BaseCommand): + """ + Invoke with: + + python manage.py cms backfill_course_outlines + """ + help = ( + "Backfill missing course outlines. This will queue a celery task for " + "each course with a missing outline, meaning that the outlines may be " + "generated minutes or hours after this script has finished running." + ) + + def add_arguments(self, parser): + parser.add_argument( + '--dry', + action='store_true', + help="Show course outlines that will be backfilled, but do not make any changes." + ) + + def handle(self, *args, **options): + dry_run = options.get('dry', False) + log.info("Starting backfill_course_outlines{}".format(" (dry run)" if dry_run else "")) + + all_course_keys_qs = CourseOverview.objects.values_list('id', flat=True) + # .difference() is not supported in MySQL, but this at least does the + # SELECT NOT IN... subquery in the database rather than Python. + missing_outlines_qs = all_course_keys_qs.exclude( + id__in=get_course_keys_with_outlines() + ) + num_courses_needing_outlines = len(missing_outlines_qs) + log.info( + "Found %d courses without outlines. Queuing tasks...", + num_courses_needing_outlines + ) + + for course_key in missing_outlines_qs: + if key_supports_outlines(course_key): + log.info("Queuing outline creation for %s", course_key) + if not dry_run: + update_outline_from_modulestore_task.delay(str(course_key)) + else: + log.info("Outlines not supported for %s - skipping", course_key) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_outlines.py b/cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_outlines.py new file mode 100644 index 0000000000..845b620472 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/tests/test_backfill_course_outlines.py @@ -0,0 +1,117 @@ +""" +Tests for `backfill_course_outlines` Studio (cms) management command. +""" +from django.core.management import call_command +from opaque_keys.edx.keys import CourseKey + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.content.learning_sequences.api import get_course_keys_with_outlines +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from ....outlines import update_outline_from_modulestore + + +class BackfillCourseOutlinesTest(SharedModuleStoreTestCase): + """ + Test `backfill_orgs_and_org_courses`. + """ + def setUp(self): + """ + Create the CourseOverviews we need for this test case. + + There's no publish signal, so we manually create the CourseOverviews. + Without that, backfill_orgs_and_org_courses has no way to figure out + which courses exist, which it needs in order to figure out which ones + need backfilling. + + We can't turn on the course_published signal because if we did so, then + the outlines would get generated automatically, and there'd be nothing + to backfill. + """ + super().setUp() + CourseOverview.update_select_courses(self.course_keys, force_update=True) + + @classmethod + def setUpClass(cls): + """ + We set up some content here, without publish signals enabled. + """ + super().setUpClass() + course_run_ids = [ + "OpenEdX/OutlineCourse/OldMongoRun1", + "course-v1:OpenEdX+OutlineCourse+Run2", + "course-v1:OpenEdX+OutlineCourse+Run3", + ] + cls.course_keys = [ + CourseKey.from_string(course_run_id) for course_run_id in course_run_ids + ] + for course_key in cls.course_keys: + if course_key.deprecated: + store_type = ModuleStoreEnum.Type.mongo + else: + store_type = ModuleStoreEnum.Type.split + + with cls.store.default_store(store_type): + course = CourseFactory.create( + org=course_key.org, + number=course_key.course, + run=course_key.run, + display_name=f"Outline Backfill Test Course {course_key.run}" + ) + with cls.store.bulk_operations(course_key): + section = ItemFactory.create( + parent_location=course.location, + category="chapter", + display_name="A Section" + ) + sequence = ItemFactory.create( + parent_location=section.location, + category="sequential", + display_name="A Sequence" + ) + unit = ItemFactory.create( + parent_location=sequence.location, + category="vertical", + display_name="A Unit" + ) + ItemFactory.create( + parent_location=unit.location, + category="html", + display_name="An HTML Module" + ) + + def test_end_to_end(self): + """Normal invocation, it should skip only the Old Mongo course.""" + # In the beginning, we have no outlines... + assert not get_course_keys_with_outlines().exists() + + # Run command and outlines appear for Split Mongo courses... + call_command("backfill_course_outlines") + course_keys_with_outlines = set(get_course_keys_with_outlines()) + assert course_keys_with_outlines == { + CourseKey.from_string("course-v1:OpenEdX+OutlineCourse+Run2"), + CourseKey.from_string("course-v1:OpenEdX+OutlineCourse+Run3"), + } + + def test_partial(self): + """Also works when we've manually created one in advance.""" + course_keys_with_outlines = set(get_course_keys_with_outlines()) + assert not get_course_keys_with_outlines().exists() + + # Manually create one + update_outline_from_modulestore( + CourseKey.from_string("course-v1:OpenEdX+OutlineCourse+Run2") + ) + assert set(get_course_keys_with_outlines()) == { + CourseKey.from_string("course-v1:OpenEdX+OutlineCourse+Run2") + } + + # backfill command should fill in the other + call_command("backfill_course_outlines") + course_keys_with_outlines = set(get_course_keys_with_outlines()) + assert course_keys_with_outlines == { + CourseKey.from_string("course-v1:OpenEdX+OutlineCourse+Run2"), + CourseKey.from_string("course-v1:OpenEdX+OutlineCourse+Run3"), + } diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py index fb7ed333ad..efbe888e28 100644 --- a/cms/djangoapps/contentstore/signals/handlers.py +++ b/cms/djangoapps/contentstore/signals/handlers.py @@ -19,6 +19,7 @@ from common.djangoapps.track.event_transaction_utils import get_event_transactio from common.djangoapps.util.module_utils import yield_dynamic_descriptor_descendants from lms.djangoapps.grades.api import task_compute_all_grades_for_course from openedx.core.djangoapps.credit.signals import on_course_publish +from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines from openedx.core.lib.gating import api as gating_api from xmodule.modulestore.django import SignalHandler, modulestore @@ -65,7 +66,8 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable= # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded from cms.djangoapps.contentstore.tasks import update_outline_from_modulestore_task, update_search_index - update_outline_from_modulestore_task.delay(str(course_key)) + if key_supports_outlines(course_key): + update_outline_from_modulestore_task.delay(str(course_key)) # Finally call into the course search subsystem # to kick off an indexing action diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index 1a2780fb93..51a15273bc 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -42,6 +42,7 @@ from cms.djangoapps.contentstore.utils import initialize_permissions, reverse_us from cms.djangoapps.models.settings.course_metadata import CourseMetadata from common.djangoapps.course_action_state.models import CourseRerunState from common.djangoapps.student.auth import has_course_author_access +from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines from openedx.core.djangoapps.embargo.models import CountryAccessRule, RestrictedCourse from openedx.core.lib.extract_tar import safetar_extractall from xmodule.contentstore.django import contentstore @@ -571,6 +572,16 @@ def update_outline_from_modulestore_task(course_key_str): """ try: course_key = CourseKey.from_string(course_key_str) + if not key_supports_outlines(course_key): + LOGGER.warning( + ( + "update_outline_from_modulestore_task called for course key" + " %s, which does not support learning_sequence outlines." + ), + course_key_str + ) + return + update_outline_from_modulestore(course_key) except Exception: # pylint disable=broad-except LOGGER.exception("Could not create course outline for course %s", course_key_str) diff --git a/openedx/core/djangoapps/content/learning_sequences/admin.py b/openedx/core/djangoapps/content/learning_sequences/admin.py new file mode 100644 index 0000000000..2b3d7d2f68 --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/admin.py @@ -0,0 +1,96 @@ +""" +Read-only Django Admin for viewing Learning Sequences and Outline data. +""" +from datetime import datetime +from enum import Enum +import json + +from django.contrib import admin +from django.utils.html import format_html +from opaque_keys import OpaqueKey +import attr + +from .api import get_course_outline +from .models import LearningContext + + +class LearningContextAdmin(admin.ModelAdmin): + """ + This is a read-only model admin that is meant to be useful for querying. + + Writes are disabled, because: + + 1. These values are auto-built/updated based on course publishes. + 2. These are read either the Studio or LMS process, but it's only supposed + to be written to from the Studio process. + """ + list_display = ( + 'context_key', + 'title', + 'published_at', + 'published_version', + 'modified' + ) + readonly_fields = ( + 'context_key', + 'title', + 'published_at', + 'published_version', + 'created', + 'modified', + 'outline', + ) + search_fields = ['context_key', 'title'] + actions = None + + def outline(self, obj): + """ + Computed attribute that shows the outline JSON in the detail view. + """ + def json_serializer(_obj, _field, value): + if isinstance(value, OpaqueKey): + return str(value) + elif isinstance(value, Enum): + return value.value + elif isinstance(value, datetime): + return value.isoformat() + return value + + outline_data = get_course_outline(obj.context_key) + outline_data_dict = attr.asdict( + outline_data, + recurse=True, + value_serializer=json_serializer, + ) + outline_data_json = json.dumps(outline_data_dict, indent=2, sort_keys=True) + return format_html("
\n{}\n
", outline_data_json) + + def has_add_permission(self, request): + """ + Disallow additions. See docstring for has_change_permission() + """ + return False + + def has_change_permission(self, request, obj=None): + """ + Disallow edits. + + This app rebuilds automatically based off of course publishes. Any + manual edits will be wiped out the next time someone touches the course, + so it's better to disallow this in the admin rather than to pretend this + works and have it suddenly change back when someone edits the course. + """ + return False + + def has_delete_permission(self, request, obj=None): + """ + Disallow deletes. + + Deleting these models can have far reaching consequences and delete a + lot of related data in other parts of the application/project. We should + only do update through the API, which allows us to rebuild the outlines. + """ + return False + + +admin.site.register(LearningContext, LearningContextAdmin) diff --git a/openedx/core/djangoapps/content/learning_sequences/api/__init__.py b/openedx/core/djangoapps/content/learning_sequences/api/__init__.py index e64f8bc293..e07639b24f 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/__init__.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/__init__.py @@ -1,7 +1,9 @@ # lint-amnesty, pylint: disable=missing-module-docstring from .outlines import ( + get_course_keys_with_outlines, get_course_outline, get_user_course_outline, get_user_course_outline_details, + key_supports_outlines, replace_course_outline, ) diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index 764a000af1..78814d2dae 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -10,10 +10,13 @@ from typing import Optional # lint-amnesty, pylint: disable=unused-import import attr # lint-amnesty, pylint: disable=unused-import from django.contrib.auth import get_user_model +from django.db.models.query import QuerySet from django.db import transaction from edx_django_utils.cache import TieredCache, get_cache_key # lint-amnesty, pylint: disable=unused-import from edx_django_utils.monitoring import function_trace -from opaque_keys.edx.keys import CourseKey, UsageKey # lint-amnesty, pylint: disable=unused-import +from opaque_keys import OpaqueKey +from opaque_keys.edx.locator import LibraryLocator +from opaque_keys.edx.keys import CourseKey # lint-amnesty, pylint: disable=unused-import from ..data import ( CourseLearningSequenceData, @@ -46,13 +49,42 @@ log = logging.getLogger(__name__) # Public API... __all__ = [ + 'get_course_keys_with_outlines', 'get_course_outline', 'get_user_course_outline', 'get_user_course_outline_details', + 'key_supports_outlines', 'replace_course_outline', ] +def key_supports_outlines(opaque_key: OpaqueKey) -> bool: + """ + Does this key-type support outlines? + + Allow all non-deprecated CourseKeys except for v1 Libraries (which subclass + CourseKey but shouldn't). So our normal SplitMongo courses (CourseLocator) + will work, as will CCX courses. But libraries, pathways, and Old Mongo + courses will not. + """ + # Get LibraryLocators out of the way first because they subclass CourseKey. + if isinstance(opaque_key, LibraryLocator): + return False + + # All other CourseKey types are acceptable if they're not deprecated. There + # are only two at the moment though, course-v1: and ccx-v1:. The old slash- + # separated course IDs (Org/Course/Run) are not supported. + if isinstance(opaque_key, CourseKey): + return not opaque_key.deprecated + + return False + + +def get_course_keys_with_outlines() -> QuerySet: + """Queryset of ContextKeys, iterable as a flat list.""" + return LearningContext.objects.values_list('context_key', flat=True) + + def get_course_outline(course_key: CourseKey) -> CourseOutlineData: """ Get the outline of a course run. diff --git a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py index ab3e08a114..dc57e7d855 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py @@ -3,22 +3,23 @@ Top level API tests. Tests API public contracts only. Do not import/create/mock models for this app. """ from datetime import datetime, timezone -from mock import patch +from unittest import TestCase + import pytest import attr from django.conf import settings from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user from edx_proctoring.exceptions import ProctoredExamNotFoundException from edx_when.api import set_dates_for_course -from opaque_keys.edx.keys import CourseKey, UsageKey # lint-amnesty, pylint: disable=unused-import -from opaque_keys.edx.locator import BlockUsageLocator # lint-amnesty, pylint: disable=unused-import +from mock import patch +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import LibraryLocator from edx_toggles.toggles.testutils import override_waffle_flag from lms.djangoapps.courseware.tests.factories import BetaTesterFactory from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG from common.djangoapps.student.auth import user_has_role -from common.djangoapps.student.models import CourseEnrollment # lint-amnesty, pylint: disable=unused-import from common.djangoapps.student.roles import CourseBetaTesterRole from ...data import ( @@ -33,11 +34,25 @@ from ..outlines import ( get_course_outline, get_user_course_outline, get_user_course_outline_details, - replace_course_outline + key_supports_outlines, + replace_course_outline, ) from .test_data import generate_sections +class OutlineSupportTestCase(TestCase): + """ + Make sure we know what kinds of course-like keys we support for outlines. + """ + def test_supported_types(self): + assert key_supports_outlines(CourseKey.from_string("course-v1:edX+100+2021")) + assert key_supports_outlines(CourseKey.from_string("ccx-v1:edX+100+2021+ccx@1")) + + def test_unsupported_types(self): + assert not key_supports_outlines(CourseKey.from_string("edX/100/2021")) + assert not key_supports_outlines(LibraryLocator(org="edX", library="100")) + + class CourseOutlineTestCase(CacheIsolationTestCase): """ Simple tests around reading and writing CourseOutlineData. No user info. diff --git a/openedx/core/djangoapps/content/learning_sequences/apps.py b/openedx/core/djangoapps/content/learning_sequences/apps.py index abf2744f98..5c2fbd1704 100644 --- a/openedx/core/djangoapps/content/learning_sequences/apps.py +++ b/openedx/core/djangoapps/content/learning_sequences/apps.py @@ -7,7 +7,7 @@ from edx_proctoring.runtime import set_runtime_service class LearningSequencesConfig(AppConfig): # lint-amnesty, pylint: disable=missing-class-docstring name = 'openedx.core.djangoapps.content.learning_sequences' - verbose_name = _('Learning Sequences') + verbose_name = _('Learning Sequences and Outlines') def ready(self): # Register celery workers diff --git a/openedx/core/djangoapps/content/learning_sequences/migrations/0008_add_learning_context_title_index.py b/openedx/core/djangoapps/content/learning_sequences/migrations/0008_add_learning_context_title_index.py new file mode 100644 index 0000000000..28e2e775ba --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/migrations/0008_add_learning_context_title_index.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.19 on 2021-03-04 16:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('learning_sequences', '0007_coursesequenceexam'), + ] + + operations = [ + migrations.AlterField( + model_name='learningcontext', + name='title', + field=models.CharField(db_index=True, max_length=255), + ), + ] diff --git a/openedx/core/djangoapps/content/learning_sequences/models.py b/openedx/core/djangoapps/content/learning_sequences/models.py index 1bad6a2757..23f8770d5e 100644 --- a/openedx/core/djangoapps/content/learning_sequences/models.py +++ b/openedx/core/djangoapps/content/learning_sequences/models.py @@ -58,15 +58,18 @@ class LearningContext(TimeStampedModel): context_key = LearningContextKeyField( max_length=255, db_index=True, unique=True, null=False ) - title = models.CharField(max_length=255) + title = models.CharField(max_length=255, db_index=True) published_at = models.DateTimeField(null=False) published_version = models.CharField(max_length=255) class Meta: indexes = [ - models.Index(fields=['-published_at']) + models.Index(fields=['-published_at']), ] + def __str__(self): + return f"LearningContext for {self.context_key}" + class CourseContext(TimeStampedModel): """ diff --git a/openedx/core/djangoapps/content/learning_sequences/views.py b/openedx/core/djangoapps/content/learning_sequences/views.py index 04905ddeee..53657bb812 100644 --- a/openedx/core/djangoapps/content/learning_sequences/views.py +++ b/openedx/core/djangoapps/content/learning_sequences/views.py @@ -169,8 +169,8 @@ class CourseOutlineView(APIView): # Grab the user's outline and send our response... try: user_course_outline_details = get_user_course_outline_details(course_key, user, at_time) - except CourseOutlineData.DoesNotExist: - raise NotFound() + except CourseOutlineData.DoesNotExist as does_not_exist_err: + raise NotFound() from does_not_exist_err serializer = self.UserCourseOutlineDataSerializer(user_course_outline_details) return Response(serializer.data)