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:
96
openedx/core/djangoapps/content/learning_sequences/admin.py
Normal file
96
openedx/core/djangoapps/content/learning_sequences/admin.py
Normal 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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user