diff --git a/openedx/core/djangoapps/content/learning_sequences/api/__init__.py b/openedx/core/djangoapps/content/learning_sequences/api/__init__.py index 2ab8283a6e1f..772e1fca5880 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/__init__.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/__init__.py @@ -8,3 +8,7 @@ key_supports_outlines, replace_course_outline, ) +from .sequences import ( + get_learning_sequence, + get_learning_sequence_by_hash, +) diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index 800b085e5000..ad693fe88ef8 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -149,6 +149,7 @@ def get_course_outline(course_key: CourseKey) -> CourseOutlineData: sequence_data = CourseLearningSequenceData( usage_key=sequence_model.usage_key, + usage_key_hash=sequence_model.usage_key_hash, title=sequence_model.title, inaccessible_after_due=sec_seq_model.inaccessible_after_due, visibility=VisibilityData( @@ -474,7 +475,10 @@ def _update_sequences(course_outline: CourseOutlineData, course_context: CourseC LearningSequence.objects.update_or_create( learning_context=course_context.learning_context, usage_key=sequence_data.usage_key, - defaults={'title': sequence_data.title} + defaults={ + 'usage_key_hash': sequence_data.usage_key_hash, + 'title': sequence_data.title, + }, ) LearningSequence.objects \ .filter(learning_context=course_context.learning_context) \ diff --git a/openedx/core/djangoapps/content/learning_sequences/api/sequences.py b/openedx/core/djangoapps/content/learning_sequences/api/sequences.py new file mode 100644 index 000000000000..69962013e42b --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/api/sequences.py @@ -0,0 +1,69 @@ +""" +All business logic related to fetching generic Learning Sequences information. +By 'generic', we mean context-agnostic; the data returned by these functions +should not be specific to Courses, Libraries, Pathways, or any other context +in which Learning Sequences can exist. + +Do not import from this module directly. +Use openedx.core.djangoapps.content.learning_sequences.api -- that +__init__.py imports from here, and is a more stable place to import from. +""" +from opaque_keys.edx.keys import UsageKey + +from ..data import LearningSequenceData +from ..models import LearningSequence + + +def get_learning_sequence(sequence_key: UsageKey) -> LearningSequenceData: + """ + Load generic data for a learning sequence given its usage key. + """ + try: + sequence = LearningSequence.objects.get(usage_key=sequence_key) + except LearningSequence.DoesNotExist as exc: + raise LearningSequenceData.DoesNotExist( + f"no such sequence with usage_key='{sequence_key}'" + ) from exc + return _make_sequence_data(sequence) + + +def get_learning_sequence_by_hash(sequence_key_hash: str) -> LearningSequenceData: + """ + Load generic data for a learning sequence given the hash of its usage key. + + WARNING! This is an experimental API function! + We do not currently handle the case of usage key hash collisions. + + Before considering this API method stable, will either need to: + 1. confirm that the probability of usage key hash collision (accounting for + potentially multiple orders of magnitude of catalog growth) is acceptably + small, or + 2. declare that hash keys are only unique within a given learning context, + and update this API function to require a `learning_context_key` argument. + See TNL-8638. + """ + sequences = LearningSequence.objects.filter(usage_key_hash=sequence_key_hash) + if not sequences: + raise LearningSequenceData.DoesNotExist( + f"no such sequence with usage_key_hash={sequence_key_hash!r}" + ) + if len(sequences) > 1: + usage_keys_list = ', '.join([ + str(sequence.usage_key) for sequence in sequences + ]) + raise Exception( + f"Two or more sequences' usage keys hash to {sequence_key_hash!r}! " + f"Colliding usage keys: [{usage_keys_list}]." + ) + return _make_sequence_data(sequences[0]) + + +def _make_sequence_data(sequence: LearningSequence) -> LearningSequenceData: + """ + Build a LearningSequenceData instance from a LearningSequence model instance. + """ + return LearningSequenceData( + usage_key=sequence.usage_key, + usage_key_hash=sequence.usage_key_hash, + title=sequence.title, + ) diff --git a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py index aaf1d2ba78d4..5af2a623a873 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py @@ -1,13 +1,22 @@ # lint-amnesty, pylint: disable=missing-module-docstring +import re from datetime import datetime, timezone from unittest import TestCase -import pytest -from opaque_keys.edx.keys import CourseKey import attr +import pytest +from opaque_keys.edx.keys import CourseKey, UsageKey from ...data import ( - CourseOutlineData, CourseSectionData, CourseLearningSequenceData, VisibilityData, CourseVisibility + hash_usage_key, + CourseOutlineData, + CourseLearningSequenceData, + CourseSectionData, + CourseVisibility, + LearningSequenceData, + VisibilityData, + USAGE_KEY_HASH_LENGTH, + USAGE_KEY_HASH_PATTERN, ) @@ -151,6 +160,55 @@ def test_empty_user_partition_groups(self): ) +class TestUsageKeyHashing(TestCase): + """ + Basic sanity validation for usage key hashing functionality. + """ + sequence_key = UsageKey.from_string('block-v1:A+B+C+type@sequential+block@D') + sequence_title = 'Sequence ABCD!' + + def test_make_sequence_data_with_auto_hash(self): + """ + If no usage_key_hash is specified, we calculate a default using hash_usage_key. + """ + sequence_data = LearningSequenceData( + usage_key=self.sequence_key, + title=self.sequence_title, + ) + assert sequence_data.usage_key == self.sequence_key + assert sequence_data.usage_key_hash == hash_usage_key(self.sequence_key) + + def test_make_sequence_data_with_explicit_hash(self): + """ + An explicit hash key be can be specified instead, for whatever reason. + """ + sequence_data = LearningSequenceData( + usage_key=self.sequence_key, + usage_key_hash="c00lhash", + title=self.sequence_title, + ) + assert sequence_data.usage_key == self.sequence_key + assert sequence_data.usage_key_hash == "c00lhash" + + def test_hash_usage_key_output(self): + """ + Compare the hash of `self.sequence_key` against certain expections we + have of it, including its literal value. + + This test is meant to fail if the algorithm behind `hash_usage_key` + is modified. It is not implausible that we will want to tweak the algorithm + one day, but this test exists to make sure that doing so is a conscious + decision, taking into account all the potential downstream effects + (URLs breaking, etc). + """ + hash_output = hash_usage_key(self.sequence_key) + + assert len(hash_output) == USAGE_KEY_HASH_LENGTH + assert re.match(rf"^{USAGE_KEY_HASH_PATTERN}$", hash_output) + + assert hash_output == 'n3Ayh9YV' + + def generate_sections(course_key, num_sequences): """ Generate a list of CourseSectionData. diff --git a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_sequences.py b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_sequences.py new file mode 100644 index 000000000000..d02e3b7e9dae --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_sequences.py @@ -0,0 +1,135 @@ +""" +Tests for generic sequence-featching API tests. + +Use the learning_sequences outlines API to create test data. +Do not import/create/mock learning_sequences models directly. +""" +from datetime import datetime, timezone + +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey + +from ...api import replace_course_outline +from ...data import ( + hash_usage_key, + CourseLearningSequenceData, + CourseSectionData, + CourseOutlineData, + CourseVisibility, + LearningSequenceData, + VisibilityData, +) +from ..sequences import get_learning_sequence, get_learning_sequence_by_hash +from .test_data import generate_sections + + +class GetLearningSequenceTestCase(TestCase): + """ + Test get_learning_sequence and get_learning_sequence_by_hash. + """ + + common_course_outline_fields = dict( + published_at=datetime(2021, 8, 12, tzinfo=timezone.utc), + entrance_exam_id=None, + days_early_for_beta=None, + self_paced=False, + course_visibility=CourseVisibility.PRIVATE, + ) + + @classmethod + def setUpTestData(cls): + """ + Set up test data, to be reusable across all tests in this class. + """ + super().setUpTestData() + cls.course_key = CourseKey.from_string("course-v1:Open-edX+Learn+GetSeq") + cls.course_outline = CourseOutlineData( + course_key=cls.course_key, + title="Get Learning Sequences Test Course!", + published_version="5ebece4b69cc593d82fe2021", + sections=generate_sections(cls.course_key, [0, 2, 1]), + **cls.common_course_outline_fields, + ) + replace_course_outline(cls.course_outline) + cls.sequence_key = cls.course_outline.sections[1].sequences[1].usage_key + cls.sequence_key_hash = hash_usage_key(cls.sequence_key) + cls.fake_sequence_key = cls.course_key.make_usage_key('sequential', 'fake_sequence') + cls.fake_sequence_key_hash = hash_usage_key(cls.fake_sequence_key) + + def test_get_learning_sequence_not_found(self): + with self.assertRaises(LearningSequenceData.DoesNotExist): + get_learning_sequence(self.fake_sequence_key) + + def test_get_learning_sequence_by_hash_not_found(self): + with self.assertRaises(LearningSequenceData.DoesNotExist): + get_learning_sequence_by_hash(self.fake_sequence_key_hash) + + def test_get_learning_sequence(self): + sequence = get_learning_sequence(self.sequence_key) + assert isinstance(sequence, LearningSequenceData) + assert sequence.usage_key == self.sequence_key + assert sequence.usage_key_hash == self.sequence_key_hash + + def test_get_learning_sequence_by_hash(self): + sequence = get_learning_sequence_by_hash(self.sequence_key_hash) + assert isinstance(sequence, LearningSequenceData) + assert sequence.usage_key == self.sequence_key + assert sequence.usage_key_hash == self.sequence_key_hash + + def test_get_learning_sequence_hash_collision(self): + normal_visibility = VisibilityData( + hide_from_toc=False, + visible_to_staff_only=False + ) + course_key_1 = CourseKey.from_string("course-v1:Open-edX+Learn+Collide1") + outline_1 = CourseOutlineData( + course_key=course_key_1, + title="Learning Sequences - Collision Course 1", + published_version="5ebece4b79cc593d82fe2021", + sections=[ + CourseSectionData( + usage_key=course_key_1.make_usage_key('chapter', 'ch_a'), + title="Chapter A", + visibility=normal_visibility, + sequences=[ + CourseLearningSequenceData( + usage_key=course_key_1.make_usage_key('sequential', 'seq_a'), + usage_key_hash="2COLLIDE", + title="Seq A", + visibility=normal_visibility, + ) + ] + ) + ], + **self.common_course_outline_fields, + ) + course_key_2 = CourseKey.from_string("course-v1:Open-edX+Learn+Collide2") + outline_2 = CourseOutlineData( + course_key=course_key_2, + title="Learning Sequences - Collision Course 2", + published_version="5ebece4b89cc593d82fe2021", + sections=[ + CourseSectionData( + usage_key=course_key_2.make_usage_key('chapter', 'ch_a'), + title="Chapter A", + visibility=normal_visibility, + sequences=[ + CourseLearningSequenceData( + usage_key=course_key_2.make_usage_key('sequential', 'seq_a'), + usage_key_hash="2COLLIDE", + title="Seq A", + visibility=normal_visibility, + ) + ] + ) + ], + **self.common_course_outline_fields, + ) + replace_course_outline(outline_1) + replace_course_outline(outline_2) + with self.assertRaises(Exception) as exc: + get_learning_sequence_by_hash("2COLLIDE") + message = str(exc.exception) + assert "Two or more sequences" in message + assert str(outline_1.sections[0].sequences[0].usage_key) in message + assert str(outline_2.sections[0].sequences[0].usage_key) in message diff --git a/openedx/core/djangoapps/content/learning_sequences/data.py b/openedx/core/djangoapps/content/learning_sequences/data.py index d7efe0e0a166..e424a4b55851 100644 --- a/openedx/core/djangoapps/content/learning_sequences/data.py +++ b/openedx/core/djangoapps/content/learning_sequences/data.py @@ -20,7 +20,10 @@ TODO: Validate all datetimes to be UTC. """ +import hashlib import logging +import math +from base64 import urlsafe_b64encode from datetime import datetime from enum import Enum from typing import Dict, FrozenSet, List, Optional @@ -116,17 +119,34 @@ def user_partition_groups_not_empty(instance, attribute, value): # pylint: disa @attr.s(frozen=True) -class CourseLearningSequenceData: +class LearningSequenceData: """ - A Learning Sequence (a.k.a. subsection) from a Course. + A generic, context-agnostic Learning Sequence. - It's possible that at some point we'll want a LearningSequenceData - superclass to encapsulate the minimum set of data that is shared between - learning sequences in Courses vs. Pathways vs. Libraries. Such an object - would likely not have `visibility` as that holds course-specific concepts. + This class does not contain any context-specific (that is, Course-specific, + Pathway-specific, etc) data. """ + class DoesNotExist(ObjectDoesNotExist): + pass + usage_key = attr.ib(type=UsageKey) title = attr.ib(type=str) + + # A URL-safe Base64-encoding of a blake2b hash of the usage key. + # For aesthetic use (eg, as a path parameter, to shorten URLs). + # This field is experimental. See TNL-8638 for more information. + usage_key_hash = attr.ib(type=str) + + @usage_key_hash.default + def _get_default_usage_key_hash(self) -> str: + return hash_usage_key(self.usage_key) + + +@attr.s(frozen=True) +class CourseLearningSequenceData(LearningSequenceData): + """ + A Learning Sequence (a.k.a. subsection) from a Course. + """ visibility = attr.ib(type=VisibilityData, default=VisibilityData()) exam = attr.ib(type=ExamData, default=ExamData()) inaccessible_after_due = attr.ib(type=bool, default=False) @@ -365,3 +385,50 @@ class UserCourseOutlineDetailsData: outline: UserCourseOutlineData schedule: ScheduleData special_exam_attempts: SpecialExamAttemptData + + +# Length, in characters, of a base64-encoded usage key hash. +# These hashes are being experimentally used to shorten courseware URLs. +# See TNL-8638 for more details. +USAGE_KEY_HASH_LENGTH = 8 + +# Number of hash digest bits needed in order to produce a base64-encoded output +# of length `USAGE_KEY_HASH_LENGTH`. +# Each base64 character captures 6 bits (because 2 ^ 6 = 64). +_USAGE_KEY_HASH_BITS = USAGE_KEY_HASH_LENGTH * 6 + +# Number of hash digest bytes needed in order to produce a base64-encoded output +# of length `USAGE_KEY_HASH_LENGTH`. Is equal to one eighth of `_USAGE_KEY_HASH_BITS`. +# In the event that _USAGE_KEY_HASH_BITS is not divisible by 8, we round up. +# We will cut off the extra any output at the end of `hash_usage_key`. +_USAGE_KEY_HASH_BYTES = math.ceil(_USAGE_KEY_HASH_BITS / 8) + +# A regex to capture strings that could be the output of `hash_usage_key`. +# Captures a string of length `USAGE_KEY_HASH_LENGTH`, made up of letters, +# numbers, dashes, underscores, and/or equals signs. +USAGE_KEY_HASH_PATTERN = rf'(?P[A-Z_a-z0-9=-]{{{USAGE_KEY_HASH_LENGTH}}})' + + +def hash_usage_key(usage_key: UsageKey) -> str: + """ + Get the blake2b hash key for the given usage_key and encode the value. + + Encoding is URL-safe Base64, which includes (case-sensitive) letters, + numbers, and the dash (-), underscore (_) and equals (=) characters. + + Args: + usage_key: the id of the location to which to generate the path + + Returns: + The string of the encoded hashed key, of length `USAGE_KEY_HASH_LENGTH`. + """ + usage_key_bytes = bytes(str(usage_key), 'utf-8') + usage_key_hash_bytes = hashlib.blake2b( + usage_key_bytes, digest_size=_USAGE_KEY_HASH_BYTES + ).digest() + encoded_hash_bytes = urlsafe_b64encode(usage_key_hash_bytes) + encoded_hash = str(encoded_hash_bytes, 'utf-8') + # When `USAGE_KEY_HASH_LENGTH` is divisible by 4, + # `encoded_hash` should end up equal to `trimmed_encoded_hash`. + trimmed_encoded_hash = encoded_hash[:USAGE_KEY_HASH_LENGTH] + return trimmed_encoded_hash diff --git a/openedx/core/djangoapps/content/learning_sequences/migrations/0017_usage_key_hash_and_unique_usage_key.py b/openedx/core/djangoapps/content/learning_sequences/migrations/0017_usage_key_hash_and_unique_usage_key.py new file mode 100644 index 000000000000..93d214e1acc1 --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/migrations/0017_usage_key_hash_and_unique_usage_key.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.24 on 2021-08-11 20:48 + +from django.db import migrations, models +import opaque_keys.edx.django.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('learning_sequences', '0016_through_model_for_user_partition_groups_2'), + ] + + operations = [ + migrations.AddField( + model_name='learningsequence', + name='usage_key_hash', + field=models.CharField(default=None, max_length=8, null=True), + ), + migrations.AlterField( + model_name='learningsequence', + name='usage_key', + field=opaque_keys.edx.django.models.UsageKeyField(max_length=255, unique=True), + ), + migrations.AlterUniqueTogether( + name='learningsequence', + unique_together={('learning_context', 'usage_key_hash')}, + ), + ] diff --git a/openedx/core/djangoapps/content/learning_sequences/models.py b/openedx/core/djangoapps/content/learning_sequences/models.py index de0881abe5f6..48252d1e2573 100644 --- a/openedx/core/djangoapps/content/learning_sequences/models.py +++ b/openedx/core/djangoapps/content/learning_sequences/models.py @@ -43,7 +43,7 @@ from opaque_keys.edx.django.models import ( # lint-amnesty, pylint: disable=unused-import LearningContextKeyField, UsageKeyField ) -from .data import CourseVisibility +from .data import CourseVisibility, USAGE_KEY_HASH_LENGTH class LearningContext(TimeStampedModel): @@ -111,7 +111,16 @@ class LearningSequence(TimeStampedModel): learning_context = models.ForeignKey( LearningContext, on_delete=models.CASCADE, related_name='sequences' ) - usage_key = UsageKeyField(max_length=255) + usage_key = UsageKeyField(max_length=255, unique=True) + + # A URL-safe Base64-encoding of a blake2b hash of the usage key. + # For aesthetic use (eg, as a path parameter, to shorten URLs). + # This field is experimental. See TNL-8638 for more information. + usage_key_hash = models.CharField( + max_length=USAGE_KEY_HASH_LENGTH, + null=True, + default=None, + ) # Yes, it's crazy to have a title 1K chars long. But we have ones at least # 270 long, meaning we wouldn't be able to make it indexed anyway in InnoDB. @@ -120,7 +129,7 @@ class LearningSequence(TimeStampedModel): # Separate field for when this Sequence's content was last changed? class Meta: unique_together = [ - ['learning_context', 'usage_key'], + ['learning_context', 'usage_key_hash'], ] diff --git a/openedx/core/djangoapps/courseware_api/tests/test_views.py b/openedx/core/djangoapps/courseware_api/tests/test_views.py index a48a5d886929..7132c74761ef 100644 --- a/openedx/core/djangoapps/courseware_api/tests/test_views.py +++ b/openedx/core/djangoapps/courseware_api/tests/test_views.py @@ -23,6 +23,7 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from cms.djangoapps.contentstore.outlines import get_outline_from_modulestore from lms.djangoapps.certificates.api import get_certificate_url from lms.djangoapps.certificates.tests.factories import ( GeneratedCertificateFactory, LinkedInAddToProfileConfigurationFactory @@ -43,6 +44,12 @@ from common.djangoapps.student.roles import CourseInstructorRole from common.djangoapps.student.tests.factories import CourseEnrollmentCelebrationFactory, UserFactory from openedx.core.djangoapps.agreements.api import create_integrity_signature +from openedx.core.djangoapps.content.learning_sequences.api import replace_course_outline +from openedx.core.djangoapps.content.learning_sequences.data import hash_usage_key +from xmodule.data import CertificatesDisplayBehaviors +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import ItemFactory, ToyCourseFactory User = get_user_model() @@ -437,7 +444,13 @@ class SequenceApiTestViews(MasqueradeMixin, BaseCoursewareTests): @classmethod def setUpClass(cls): super().setUpClass() - cls.url = f'/api/courseware/sequence/{cls.sequence.location}' + cls.sequence_key = cls.sequence.location + cls.sequence_key_hash = hash_usage_key(cls.sequence.location) + cls.url = f'/api/courseware/sequence/{cls.sequence_key}' + cls.url_using_hash = f'/api/courseware/sequence/{cls.sequence_key_hash}' + # Manually ensure that a learning_sequences CourseOutline is created + # for `cls.course`. + replace_course_outline(get_outline_from_modulestore(cls.course.id)[0]) @classmethod def tearDownClass(cls): @@ -448,6 +461,7 @@ def test_sequence_metadata(self): response = self.client.get(self.url) assert response.status_code == 200 assert response.data['display_name'] == 'sequence' + assert response.data['usage_key_hash'] == self.sequence_key_hash assert len(response.data['items']) == 1 def test_unit_error(self): @@ -489,6 +503,12 @@ def test_hidden_after_due(self, is_past_due, masquerade_config, expected_hidden, assert response.data['is_hidden_after_due'] == expected_hidden assert bool(response.data['banner_text']) == expected_banner + def test_sequence_metadata_using_hash(self): + response_using_key = self.client.get(self.url) + response_using_hash = self.client.get(self.url_using_hash) + assert response_using_hash.status_code == 200 + assert response_using_hash.data == response_using_key.data + class ResumeApiTestViews(BaseCoursewareTests, CompletionWaffleTestMixin): """ diff --git a/openedx/core/djangoapps/courseware_api/urls.py b/openedx/core/djangoapps/courseware_api/urls.py index 12fdf8cff2b4..241f2f53540c 100644 --- a/openedx/core/djangoapps/courseware_api/urls.py +++ b/openedx/core/djangoapps/courseware_api/urls.py @@ -7,20 +7,25 @@ from django.urls import path, re_path from openedx.core.djangoapps.courseware_api import views +from openedx.core.djangoapps.content.learning_sequences.data import USAGE_KEY_HASH_PATTERN + urlpatterns = [ - re_path(fr'^course/{settings.COURSE_KEY_PATTERN}', - views.CoursewareInformation.as_view(), - name="courseware-api"), - re_path(fr'^sequence/{settings.USAGE_KEY_PATTERN}', - views.SequenceMetadata.as_view(), - name="sequence-api"), - re_path(fr'^resume/{settings.COURSE_KEY_PATTERN}', - views.Resume.as_view(), - name="resume-api"), - re_path(fr'^celebration/{settings.COURSE_KEY_PATTERN}', - views.Celebration.as_view(), - name="celebration-api"), + re_path(fr'^course/{settings.COURSE_KEY_PATTERN}$', + views.CoursewareInformation.as_view(), + name="courseware-api"), + re_path(fr'^sequence/{USAGE_KEY_HASH_PATTERN}$', + views.SequenceMetadataByHash.as_view(), + name="sequence-api-by-hash"), + re_path(fr'^sequence/{settings.USAGE_KEY_PATTERN}$', + views.SequenceMetadata.as_view(), + name="sequence-api"), + re_path(fr'^resume/{settings.COURSE_KEY_PATTERN}$', + views.Resume.as_view(), + name="resume-api"), + re_path(fr'^celebration/{settings.COURSE_KEY_PATTERN}$', + views.Celebration.as_view(), + name="celebration-api"), ] if getattr(settings, 'PROVIDER_STATES_URL', None): diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index 8232337203eb..fd68a7c4e2e4 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -49,6 +49,11 @@ from openedx.core.djangoapps.agreements.api import get_integrity_signature from openedx.core.djangoapps.courseware_api.utils import get_celebrations_dict from openedx.core.djangoapps.programs.utils import ProgramProgressMeter +from openedx.core.djangoapps.content.learning_sequences.api import ( + get_learning_sequence, + get_learning_sequence_by_hash, +) +from openedx.core.djangoapps.content.learning_sequences.data import LearningSequenceData, hash_usage_key from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.courses import get_course_by_id @@ -569,13 +574,14 @@ class SequenceMetadata(DeveloperErrorViewMixin, APIView): SessionAuthenticationAllowInactiveUser, ) - def get(self, request, usage_key_string, *args, **kwargs): # lint-amnesty, pylint: disable=unused-argument + def get(self, request, usage_key_string, **kwargs): """ Return response to a GET request. """ try: usage_key = UsageKey.from_string(usage_key_string) - except InvalidKeyError as exc: + learning_sequence = get_learning_sequence(usage_key) + except (InvalidKeyError, LearningSequenceData.DoesNotExist) as exc: raise NotFound(f"Invalid usage key: '{usage_key_string}'.") from exc _, request.user = setup_masquerade( request, @@ -583,24 +589,88 @@ def get(self, request, usage_key_string, *args, **kwargs): # lint-amnesty, pyli staff_access=has_access(request.user, 'staff', usage_key.course_key), reset_masquerade_data=True, ) + return Response(_get_sequence_metadata(request, learning_sequence)) - sequence, _ = get_module_by_usage_id( - self.request, - str(usage_key.course_key), - str(usage_key), - disable_staff_debug_info=True, - will_recheck_access=True) +class SequenceMetadataByHash(DeveloperErrorViewMixin, APIView): + """ + Variation of the SequenceMetadata view, accepting a hashed sequence + usage key instead of a normal sequence usage key. + + GET /api/courseware/sequence/{usage_key_hash} + + The hashed usage key is translated to a normal usage key via the Learning + Sequences Python API. Refer to `SequenceMetadata` for return value details. + + This allows hashed usage keys to be used in Learning MFE courseware URLs, + significantly shortening those URLs. This feature is considered + experimental, and is currently behind the `ENABLE_SHORT_MFE_URL` flag. + We will likely move the usage_key/usage_key_hash translation logic + entirely within the Learning Sequences Outlines API before globally + enabling this feature, so we ask that folks avoid relying on this new + API view for now. See TNL-8638 for details. + """ + + authentication_classes = ( + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ) - if not hasattr(sequence, 'get_metadata'): - # Looks like we were asked for metadata on something that is not a sequence (or section). - return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) + def get(self, request, usage_key_hash, **kwargs): + """ + Return response to a GET request. + """ + try: + learning_sequence = get_learning_sequence_by_hash(usage_key_hash) + except LearningSequenceData.DoesNotExist as exc: + raise NotFound(f"Invalid usage key hash: '{usage_key_hash}'.") from exc + seq_metadata = _get_sequence_metadata(request, learning_sequence) + masquerading_context = { + 'specific_masquerade': is_masquerading_as_specific_student( + request.user, usage_key.course_key + ) + } + return Response(seq_metadata, context=masquerading_context) - view = STUDENT_VIEW - if request.user.is_anonymous: - view = PUBLIC_VIEW - context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, usage_key.course_key)} - return Response(sequence.get_metadata(view=view, context=context)) +def _get_sequence_metadata(request, learning_sequence: LearningSequenceData) -> dict: + """ + Return metadata about a sequence and its units. + + For use exclusively by the Sequence Metadata HTTP APIs defined above. + """ + usage_key = learning_sequence.usage_key + + _, request.user = setup_masquerade( + request, + usage_key.course_key, + staff_access=has_access(request.user, 'staff', usage_key.course_key), + reset_masquerade_data=True, + ) + + sequence, _ = get_module_by_usage_id( + request, + str(usage_key.course_key), + str(usage_key), + disable_staff_debug_info=True, + will_recheck_access=True) + + if not hasattr(sequence, 'get_metadata'): + # Looks like we were asked for metadata on something that is not a sequence (or section). + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) + + view = STUDENT_VIEW + if request.user.is_anonymous: + view = PUBLIC_VIEW + sequence_data = sequence.get_metadata(view=view) + + # Insert usage_key_hashes of this sequence and its units into the response. + # We do this here in order to avoid further complicating seq_module + # with details of usage key hashing. + sequence_data["usage_key_hash"] = learning_sequence.usage_key_hash + for unit_data in sequence_data["items"]: + unit_data["usage_key_hash"] = hash_usage_key(unit_data["id"]) + + return sequence_data class Resume(DeveloperErrorViewMixin, APIView):