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.
This commit is contained in:
David Ormsbee
2021-03-04 11:58:00 -05:00
parent 6a00878f59
commit f5b74fcf31
12 changed files with 374 additions and 12 deletions

View File

@@ -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("<pre>\n{}\n</pre>", 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)

View File

@@ -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,
)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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),
),
]

View File

@@ -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):
"""

View File

@@ -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)