Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
a7e307f
feat: add /api/user/v2/ users, user_preferences and time_zones
Abdul-Muqadim-Arbisoft Oct 14, 2026
149e8c8
feat: publish the user API in the LMS schema
Abdul-Muqadim-Arbisoft Oct 14, 2026
e487de7
feat: standardize the account, name-change, verification and profile …
Abdul-Muqadim-Arbisoft Oct 23, 2026
72a869d
fix: let the account tests be collected under the CMS settings
Abdul-Muqadim-Arbisoft Oct 26, 2026
222917e
fix: name the library JWT class on the views that replace a default a…
Abdul-Muqadim-Arbisoft Oct 26, 2026
7cb862f
fix: align four published details across the three /api/user/v2/ phases
Abdul-Muqadim-Arbisoft Oct 26, 2026
a3e7445
test: hold the four corrected success codes to the handlers that answ…
Abdul-Muqadim-Arbisoft Oct 29, 2026
94af55b
fix: mark the superseded user addresses Studio serves as superseded t…
Abdul-Muqadim-Arbisoft Oct 29, 2026
b572b73
fix: carry the service's appended components into the documentation s…
Abdul-Muqadim-Arbisoft Oct 29, 2026
0e0124f
fix: refuse a page size no collection of this version will give
Abdul-Muqadim-Arbisoft Oct 29, 2026
cdffcf7
fix: say in words why a request was refused, not in printed data
Abdul-Muqadim-Arbisoft Oct 29, 2026
f1e6e65
fix: answer a member account address from the record the lookup resol…
Abdul-Muqadim-Arbisoft Oct 31, 2026
c9be206
fix: answer every account holding a selected email address
Abdul-Muqadim-Arbisoft Oct 31, 2026
d54e8fd
fix: admit an operation on one account by the rules of the account re…
Abdul-Muqadim-Arbisoft Oct 31, 2026
da42623
fix: describe each address by the body and the media types it reads
Abdul-Muqadim-Arbisoft Oct 31, 2026
d02caae
docs: record where the retirement of a re-used address is read differ…
Abdul-Muqadim-Arbisoft Oct 31, 2026
7da47ed
test: guard the record-visibility layer of the two queryset-backed us…
Abdul-Muqadim-Arbisoft Oct 31, 2026
eb6e0f2
fix: describe each address of this version to the caller that asks, n…
Abdul-Muqadim-Arbisoft Oct 31, 2026
ffcf6cf
fix: refuse a selected property name that reaches into a property
Abdul-Muqadim-Arbisoft Oct 31, 2026
78299b0
fix: test and describe the rate limit on changing a recovery address
Abdul-Muqadim-Arbisoft Oct 31, 2026
87047e9
fix: describe the body a search is asked for in, not the one it answe…
Abdul-Muqadim-Arbisoft Oct 31, 2026
69c5b73
fix: say both things that get a clearing of a profile image refused
Abdul-Muqadim-Arbisoft Oct 31, 2026
f4c6f0f
fix: publish the two refusals every address of this version could alr…
Abdul-Muqadim-Arbisoft Oct 31, 2026
540d11a
fix: stop naming the site's sharing configuration in a refusal about …
Abdul-Muqadim-Arbisoft Nov 1, 2026
8f6b550
fix: fetch rows carried inside another resource through the policy th…
Abdul-Muqadim-Arbisoft Nov 1, 2026
5346d5b
fix: describe the superseded user addresses the same way in both serv…
Abdul-Muqadim-Arbisoft Nov 1, 2026
cd436b7
fix: mount the prefix that answers unknown addresses after the applic…
Abdul-Muqadim-Arbisoft Nov 1, 2026
e933133
fix: serve the document published for client generation from the cach…
Abdul-Muqadim-Arbisoft Nov 1, 2026
7cc2a8e
test: hold the settings module the published reference is generated from
Abdul-Muqadim-Arbisoft Nov 1, 2026
2aef217
fix: carry inside a preference only the owner entries the caller may …
Abdul-Muqadim-Arbisoft Nov 1, 2026
33f7e92
fix: name the refusal of an address more than one account answers to
Abdul-Muqadim-Arbisoft Nov 1, 2026
45d906f
fix: record a profile image on the account the request was admitted for
Abdul-Muqadim-Arbisoft Nov 1, 2026
03b3301
fix: read an address held and left more than once through the addresses
Abdul-Muqadim-Arbisoft Nov 1, 2026
4095ee5
docs: record beside the name in an address what each address does wit…
Abdul-Muqadim-Arbisoft Nov 1, 2026
3c194e9
test: say which refusal the recording of an image without a date is e…
Abdul-Muqadim-Arbisoft Nov 1, 2026
44063e8
refactor: hold these addresses, their descriptions and the hook that …
Abdul-Muqadim-Arbisoft Nov 1, 2026
ffe739c
test: check that neither schema module reaches into the other service
Abdul-Muqadim-Arbisoft Nov 1, 2026
87ea4c5
test: hold each version to the refusal it answers with
Abdul-Muqadim-Arbisoft Nov 1, 2026
f5fd5ae
style: satisfy the repository's own lint on the lines this work adds
Abdul-Muqadim-Arbisoft Nov 1, 2026
9892aa2
fix: refuse a restricted application's token on every operation of /a…
Abdul-Muqadim-Arbisoft Nov 4, 2026
466c8bd
fix: admit the API key at the user directory and stored preferences, …
Abdul-Muqadim-Arbisoft Nov 4, 2026
3ba2cd4
fix: read an unconfirmed account's session at the email search, as v1…
Abdul-Muqadim-Arbisoft Nov 4, 2026
decbf90
fix: refuse an image whose metadata cannot be written back with a 400
Abdul-Muqadim-Arbisoft Nov 4, 2026
cbbc334
fix: publish the superseded account requests without the replacement'…
Abdul-Muqadim-Arbisoft Nov 4, 2026
9fd376d
fix: read a name, a searched address and the account selectors as sent
Abdul-Muqadim-Arbisoft Nov 4, 2026
44e95d3
fix: publish the page links as members every page carries
Abdul-Muqadim-Arbisoft Nov 4, 2026
4133581
test: pin the two refusals the account list and member add over versi…
Abdul-Muqadim-Arbisoft Nov 4, 2026
a6a154e
fix: read the directory's username selector as sent
Abdul-Muqadim-Arbisoft Nov 4, 2026
6eb8cf5
fix: tell an inactive non-staff caller the email search is not there
Abdul-Muqadim-Arbisoft Nov 4, 2026
4d0bb0c
fix: report an ambiguous account address under the username it carried
Abdul-Muqadim-Arbisoft Nov 4, 2026
dec231f
test: drive the absent-account branches through what the services raise
Abdul-Muqadim-Arbisoft Nov 4, 2026
659717b
revert: serve /lms-api/schema/ as master serves it
Abdul-Muqadim-Arbisoft Nov 4, 2026
648b3b4
fix: answer a refused method, Accept, media type or body as other API…
Abdul-Muqadim-Arbisoft Nov 4, 2026
48a013e
fix: answer an unserved address under /api/user/v2/ as the rest of th…
Abdul-Muqadim-Arbisoft Nov 4, 2026
fbec0e5
fix: leave the shared profile image helper as it was and record in ve…
Abdul-Muqadim-Arbisoft Nov 4, 2026
0846c64
test: generate the user documents once, from the user mounts only
Abdul-Muqadim-Arbisoft Nov 4, 2026
ff17d04
test: leave the existing schema tests as they were and add beside them
Abdul-Muqadim-Arbisoft Nov 4, 2026
1ec6d4c
docs: shorten the comments added to settings, URLconfs and schema mod…
Abdul-Muqadim-Arbisoft Nov 4, 2026
88bce64
fix: mark Studio's superseded user addresses through its own hook
Abdul-Muqadim-Arbisoft Nov 4, 2026
ff4e951
refactor: import the replacing serializers where lint-imports can see…
Abdul-Muqadim-Arbisoft Nov 4, 2026
45b7d17
fix: name each refused item of a list body in words
Abdul-Muqadim-Arbisoft Nov 4, 2026
e7f2c4e
feat: standardize the preferences and email opt-in APIs as /api/user/v2/
Abdul-Muqadim-Arbisoft Oct 26, 2026
178e375
fix: name the library JWT class on the views that replace a default a…
Abdul-Muqadim-Arbisoft Oct 26, 2026
6e1ce0c
fix: publish a body no request may leave out as one the operation needs
Abdul-Muqadim-Arbisoft Oct 29, 2026
80f5462
test: hold the four corrected success codes to the handlers that answ…
Abdul-Muqadim-Arbisoft Oct 29, 2026
09f3cab
fix: admit an operation on one account by the rules of the account re…
Abdul-Muqadim-Arbisoft Oct 31, 2026
123a84b
fix: describe each address by the body and the media types it reads
Abdul-Muqadim-Arbisoft Oct 31, 2026
40e758b
fix: describe the body a search is asked for in, not the one it answe…
Abdul-Muqadim-Arbisoft Oct 31, 2026
0a0931b
fix: publish the two refusals every address of this version could alr…
Abdul-Muqadim-Arbisoft Oct 31, 2026
057e7be
fix: describe the superseded user addresses the same way in both serv…
Abdul-Muqadim-Arbisoft Nov 1, 2026
fa86aeb
fix: say in the document that a change has to name a preference
Abdul-Muqadim-Arbisoft Nov 1, 2026
3c4f614
style: satisfy the repository's own lint on the lines this work adds
Abdul-Muqadim-Arbisoft Nov 1, 2026
0489b34
test: refuse a restricted application's token at every preference and…
Abdul-Muqadim-Arbisoft Nov 4, 2026
33ec17c
fix: check a preference value as it was sent, as version 1 does
Abdul-Muqadim-Arbisoft Nov 4, 2026
34285a6
fix: answer a preference name with whitespace around it as absent
Abdul-Muqadim-Arbisoft Nov 4, 2026
14a1258
refactor: drop the preference view's handler for a refusal the servic…
Abdul-Muqadim-Arbisoft Nov 4, 2026
3b4a31f
test: restore the two checks the restack dropped, without the whole d…
Abdul-Muqadim-Arbisoft Nov 4, 2026
5848989
fix: leave Studio's preference addresses current in its own document
Abdul-Muqadim-Arbisoft Nov 4, 2026
75a854e
fix: publish the superseded preference writes without the replacement…
Abdul-Muqadim-Arbisoft Nov 4, 2026
9eab6ac
feat: standardize the account retirement pipeline as /api/user/v2/
Abdul-Muqadim-Arbisoft Oct 26, 2026
e43b4c0
fix: refuse an ambiguous account name, and answer the method a caller…
Abdul-Muqadim-Arbisoft Oct 27, 2026
1a81c0c
fix: publish what a caller must send, and read every step it names
Abdul-Muqadim-Arbisoft Oct 27, 2026
16d1eaa
test: pin the claims of /api/user/v2/ that no test could fail
Abdul-Muqadim-Arbisoft Oct 27, 2026
41e6883
fix: resolve every retirement operation through its own resource
Abdul-Muqadim-Arbisoft Oct 27, 2026
702986b
fix: publish the partner-report entry the claim can actually produce
Abdul-Muqadim-Arbisoft Oct 28, 2026
4261205
fix: judge the record a request resolved, and let a document keep its…
Abdul-Muqadim-Arbisoft Oct 28, 2026
37ee875
fix: name the retirement component once, and cover its queue reads in…
Abdul-Muqadim-Arbisoft Nov 1, 2026
2879ea5
fix: keep the line a refused batch wrote about itself, and name the c…
Abdul-Muqadim-Arbisoft Nov 1, 2026
903751d
fix: say that a selected name reaching into a property is refused, an…
Abdul-Muqadim-Arbisoft Nov 1, 2026
d97f67c
fix: publish and measure the refusal every retirement address already…
Abdul-Muqadim-Arbisoft Nov 1, 2026
9b79e8f
docs: describe each retirement address to the caller that asks, not t…
Abdul-Muqadim-Arbisoft Nov 1, 2026
e896143
style: import the two modules of this version on a line each
Abdul-Muqadim-Arbisoft Nov 1, 2026
7c2c073
test: hold the superseded names Studio publishes for the retirement a…
Abdul-Muqadim-Arbisoft Nov 1, 2026
dc89ba3
style: declare the attributes a helper fills before it fills them
Abdul-Muqadim-Arbisoft Nov 1, 2026
829d3e3
fix: append the component Studio's document had stopped carrying
Abdul-Muqadim-Arbisoft Nov 1, 2026
62b5fa7
fix: keep the user retirement model out of Studio's Authoring API doc…
Abdul-Muqadim-Arbisoft Nov 4, 2026
17e4c14
fix: dispose of a whole retirement queue in one batch request
Abdul-Muqadim-Arbisoft Nov 4, 2026
05c4c3b
fix: match each name a batch disposal is given exactly as it was sent
Abdul-Muqadim-Arbisoft Nov 4, 2026
70e818b
test: read the disposal's permissions from its declaration in every r…
Abdul-Muqadim-Arbisoft Nov 4, 2026
23f1eab
test: name each refused name of a batch disposal by its position
Abdul-Muqadim-Arbisoft Nov 4, 2026
0583ff5
fix: refuse a cooling-off period or a claim limit too large to act on
Abdul-Muqadim-Arbisoft Nov 4, 2026
e89e302
refactor: read the organizations an account studied with from a share…
Abdul-Muqadim-Arbisoft Nov 4, 2026
9e556fd
refactor: judge in version 2 whether a retirement admits pipeline work
Abdul-Muqadim-Arbisoft Nov 4, 2026
8932cd0
fix: keep the retirement move whole without changing a generation set…
Abdul-Muqadim-Arbisoft Nov 4, 2026
08f351b
fix: name the retirement choice sets after the models that carry them
Abdul-Muqadim-Arbisoft Nov 4, 2026
e76dedd
style: shorten the retirement pipeline's long comments
Abdul-Muqadim-Arbisoft Nov 5, 2026
c59981f
test: read the retirement permissions from their declaration in every…
Abdul-Muqadim-Arbisoft Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions cms/lib/spectacular.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,45 @@

from drf_spectacular.openapi import AutoSchema

# Legacy operations superseded by a conforming address under /api/authoring/.
# Each is listed in the truncated spelling as well as the full one: the routes
# Legacy operations superseded by a conforming address. Each contentstore one
# is listed in the truncated spelling as well as the full one: the routes
# make the final character optional, so both addresses resolve, and which one
# the generator emits depends on how it renders that regex.
SUPERSEDED_PATHS = (
"/api/contentstore/v0/youtube_transcripts/{course_id}/chec",
"/api/contentstore/v0/youtube_transcripts/{course_id}/check",
"/api/contentstore/v0/youtube_transcripts/{course_id}/uploa",
"/api/contentstore/v0/youtube_transcripts/{course_id}/upload",
# The user API this service mounts as the LMS does; the LMS serves the replacements.
# The preference addresses are left out: this service's own pages change a
# preference through them, and it serves no replacement for them.
"/api/user/v1/me",
"/api/user/v1/accounts",
"/api/user/v1/accounts/search_emails",
"/api/user/v1/accounts/{username}",
"/api/user/v1/accounts/{username}/deactivate/",
"/api/user/v1/accounts/{username}/image",
"/api/user/v1/accounts/{username}/verification_status/",
"/api/user/v1/accounts/{username}/verifications/",
"/api/user/v1/accounts/name_change/",
"/api/user/v1/accounts/name_change/{username}/confirm/",
"/api/user/v1/users/",
"/api/user/v1/users/{id}/",
"/api/user/v1/user_prefs/",
"/api/user/v1/user_prefs/{id}/",
"/api/user/v1/preferences/{pref_key}/users/",
"/api/user/v1/forum_roles/{name}/users/",
"/api/user/v1/preferences/time_zones/",
"/api/user/v1/accounts/{username}/retirement_status/",
"/api/user/v1/accounts/retirement_queue/",
"/api/user/v1/accounts/retirements_by_status_and_date/",
"/api/user/v1/accounts/update_retirement_status/",
"/api/user/v1/accounts/retirement_cleanup/",
"/api/user/v1/accounts/retirement_partner_report/",
"/api/user/v1/accounts/retirement_partner_report_cleanup/",
"/api/user/v1/accounts/cancel_retirement/",
"/api/user/v1/accounts/retire/",
"/api/user/v1/accounts/retire_misc/",
)

# Legacy addresses of APIs migrated to /api/authoring/, marked deprecated for
Expand Down
157 changes: 155 additions & 2 deletions cms/lib/tests/test_spectacular.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"""Tests for the CMS drf-spectacular hooks and schema class."""

import re
from importlib import import_module
from pathlib import Path
from unittest import TestCase, mock

from django.conf import settings
from django.test import SimpleTestCase
from django.test import TestCase as DjangoTestCase
from django.urls import path as url_path
from drf_spectacular.generators import SchemaGenerator
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.settings import patched_settings, spectacular_settings
from rest_framework import serializers, viewsets
from rest_framework.response import Response
from rest_framework.settings import api_settings
Expand All @@ -20,6 +23,8 @@
cms_mark_migrated_paths,
cms_mark_superseded_paths,
)
from openedx.core.apidocs import get_api_docs_settings
from openedx.core.djangoapps.user_api.schema_components import PATCHED_USER_RETIREMENT_STATUS


def _endpoint(path):
Expand Down Expand Up @@ -73,7 +78,7 @@ def test_every_operation_of_a_superseded_path_is_marked(self):
assert result["paths"][path]["get"]["deprecated"] is True

def test_both_spellings_of_each_superseded_path_are_listed(self):
assert set(SUPERSEDED_PATHS) == {
assert {path for path in SUPERSEDED_PATHS if path.startswith("/api/contentstore/")} == {
"/api/contentstore/v0/youtube_transcripts/{course_id}/chec",
"/api/contentstore/v0/youtube_transcripts/{course_id}/check",
"/api/contentstore/v0/youtube_transcripts/{course_id}/uploa",
Expand Down Expand Up @@ -156,6 +161,14 @@ def test_both_hooks_are_configured(self):
assert "cms.lib.spectacular.cms_mark_superseded_paths" in hooks
assert "cms.lib.spectacular.cms_mark_migrated_paths" in hooks

def test_no_marking_hook_runs_beside_the_two_this_module_defines(self):
assert settings.SPECTACULAR_SETTINGS["POSTPROCESSING_HOOKS"] == [
"drf_spectacular.hooks.postprocess_schema_enums",
"cms.lib.spectacular.cms_mark_superseded_paths",
"cms.lib.spectacular.cms_mark_migrated_paths",
"openedx.core.apidocs.untag_deprecated_operations",
]

def test_the_default_enum_hook_is_kept_alongside_the_new_one(self):
"""Setting the key replaces drf-spectacular's default list, so it is restated."""
assert "drf_spectacular.hooks.postprocess_schema_enums" in settings.SPECTACULAR_SETTINGS["POSTPROCESSING_HOOKS"]
Expand Down Expand Up @@ -200,6 +213,9 @@ def test_is_the_default_schema_class(self):
"""Views without their own ``schema`` pick this up via REST_FRAMEWORK settings."""
assert api_settings.DEFAULT_SCHEMA_CLASS is CmsAutoSchema

def test_every_operation_is_described_by_this_class_alone(self):
assert CmsAutoSchema.__bases__ == (AutoSchema,)

def test_only_the_colliding_legacy_address_is_suffixed(self):
patterns = [
url_path("api/contentstore/v3/home/", _HomeViewSet.as_view({"get": "list"})),
Expand All @@ -215,3 +231,140 @@ def test_only_the_colliding_legacy_address_is_suffixed(self):
# Already distinct from its conforming twin's id, so it keeps its name.
assert ids["/api/authoring/v4/courses/"] == "v4_courses_list"
assert ids["/api/contentstore/v4/home/courses/"] == "v4_home_courses_list"


#: Every operation of a superseded user address this service serves, with the
#: name it is published under. The names are the ones both documents have
#: always carried: a client imports a function per name, so an operation that
#: comes back under a different one has been taken away from it.
SUPERSEDED_USER_OPERATION_IDS = {
("/api/user/v1/accounts", "get"): "api_user_v1_accounts_retrieve",
("/api/user/v1/accounts/name_change/", "post"): "api_user_v1_accounts_name_change_create",
("/api/user/v1/accounts/name_change/{username}/confirm/", "post"): (
"api_user_v1_accounts_name_change_confirm_create"
),
("/api/user/v1/accounts/search_emails", "post"): "api_user_v1_accounts_search_emails_create",
("/api/user/v1/accounts/{username}", "get"): "api_user_v1_accounts_retrieve_2",
("/api/user/v1/accounts/{username}", "patch"): "api_user_v1_accounts_partial_update",
("/api/user/v1/accounts/{username}/deactivate/", "post"): "api_user_v1_accounts_deactivate_create",
("/api/user/v1/accounts/{username}/image", "delete"): "api_user_v1_accounts_image_destroy",
("/api/user/v1/accounts/{username}/image", "post"): "api_user_v1_accounts_image_create",
("/api/user/v1/accounts/{username}/verification_status/", "get"): (
"api_user_v1_accounts_verification_status_retrieve"
),
("/api/user/v1/accounts/{username}/verifications/", "get"): "api_user_v1_accounts_verifications_list",
("/api/user/v1/forum_roles/{name}/users/", "get"): "api_user_v1_forum_roles_users_list",
("/api/user/v1/me", "get"): "api_user_v1_me_retrieve",
("/api/user/v1/preferences/email_opt_in/", "post"): "api_user_v1_preferences_email_opt_in_create",
("/api/user/v1/preferences/time_zones/", "get"): "api_user_v1_preferences_time_zones_list",
("/api/user/v1/preferences/{pref_key}/users/", "get"): "api_user_v1_preferences_users_list",
("/api/user/v1/preferences/{username}", "get"): "api_user_v1_preferences_retrieve",
("/api/user/v1/preferences/{username}", "patch"): "api_user_v1_preferences_partial_update",
("/api/user/v1/preferences/{username}/{preference_key}", "delete"): "api_user_v1_preferences_destroy",
("/api/user/v1/preferences/{username}/{preference_key}", "get"): "api_user_v1_preferences_retrieve_2",
("/api/user/v1/preferences/{username}/{preference_key}", "put"): "api_user_v1_preferences_update",
("/api/user/v1/user_prefs/", "get"): "api_user_v1_user_prefs_list",
("/api/user/v1/user_prefs/{id}/", "get"): "api_user_v1_user_prefs_retrieve",
("/api/user/v1/users/", "get"): "api_user_v1_users_list",
("/api/user/v1/users/{id}/", "get"): "api_user_v1_users_retrieve",
("/api/user/v1/accounts/cancel_retirement/", "post"): "api_user_v1_accounts_cancel_retirement_create",
("/api/user/v1/accounts/retire/", "post"): "api_user_v1_accounts_retire_create",
("/api/user/v1/accounts/retire_misc/", "post"): "api_user_v1_accounts_retire_misc_create",
("/api/user/v1/accounts/retirement_cleanup/", "post"): "api_user_v1_accounts_retirement_cleanup_create",
("/api/user/v1/accounts/retirement_partner_report/", "post"): (
"api_user_v1_accounts_retirement_partner_report_create"
),
("/api/user/v1/accounts/retirement_partner_report/", "put"): (
"api_user_v1_accounts_retirement_partner_report_update"
),
("/api/user/v1/accounts/retirement_partner_report_cleanup/", "post"): (
"api_user_v1_accounts_retirement_partner_report_cleanup_create"
),
("/api/user/v1/accounts/retirement_queue/", "get"): "api_user_v1_accounts_retirement_queue_retrieve",
("/api/user/v1/accounts/retirements_by_status_and_date/", "get"): (
"api_user_v1_accounts_retirements_by_status_and_date_retrieve"
),
("/api/user/v1/accounts/update_retirement_status/", "patch"): (
"api_user_v1_accounts_update_retirement_status_partial_update"
),
("/api/user/v1/accounts/{username}/retirement_status/", "get"): "api_user_v1_accounts_retirement_status_retrieve",
}


#: The user addresses this service marks superseded.
SUPERSEDED_USER_ADDRESSES = tuple(path for path in SUPERSEDED_PATHS if path.startswith("/api/user/"))

#: The preference operations this service's own pages call. It serves no
#: replacement for them, so its document publishes them as current.
CURRENT_PREFERENCE_OPERATIONS = frozenset({
("/api/user/v1/preferences/{username}", "get"),
("/api/user/v1/preferences/{username}", "patch"),
("/api/user/v1/preferences/{username}/{preference_key}", "delete"),
("/api/user/v1/preferences/{username}/{preference_key}", "get"),
("/api/user/v1/preferences/{username}/{preference_key}", "put"),
("/api/user/v1/preferences/email_opt_in/", "post"),
})


class SupersededUserOperationTest(DjangoTestCase):
"""
The user addresses this service serves beside the LMS, which serves their replacements.

Both services mount that API at the same addresses and both publish an
unauthenticated document of everything they serve. This one marks the
addresses it lists as superseded and keeps the name each operation is
published under.
"""

@classmethod
def setUpClass(cls):
super().setUpClass()
patterns = [
entry for entry in import_module(settings.ROOT_URLCONF).urlpatterns
if str(entry.pattern).startswith("api/user/")
]
# The whole document derives its path prefix from every address the
# service serves, which share nothing past the root.
with patched_settings({**get_api_docs_settings(), "SCHEMA_PATH_PREFIX": "/"}):
cls.schema = SchemaGenerator(patterns=patterns).get_schema(request=None, public=True)

def _operations(self):
"""Every operation of the user addresses that have a replacement, as this document carries it."""
return {
(address, method): self.schema["paths"][address][method]
for address, method in SUPERSEDED_USER_OPERATION_IDS
}

def test_this_service_serves_every_address_it_marks(self):
"""The mark is matched on the exact address, so one this document spells otherwise is never marked."""
assert [address for address in SUPERSEDED_USER_ADDRESSES if address not in self.schema["paths"]] == []

def test_every_operation_of_the_addresses_it_lists_is_marked_superseded(self):
marked = {
key: operation.get("deprecated") for key, operation in self._operations().items()
if key not in CURRENT_PREFERENCE_OPERATIONS
}
assert marked == dict.fromkeys(set(SUPERSEDED_USER_OPERATION_IDS) - CURRENT_PREFERENCE_OPERATIONS, True)

def test_the_preference_addresses_its_own_pages_call_are_published_as_current(self):
operations = self._operations()
marked = {key: "deprecated" in operations[key] for key in CURRENT_PREFERENCE_OPERATIONS}
assert marked == dict.fromkeys(CURRENT_PREFERENCE_OPERATIONS, False)

def test_every_operation_keeps_the_name_it_is_published_under(self):
published = {key: operation["operationId"] for key, operation in self._operations().items()}
assert published == SUPERSEDED_USER_OPERATION_IDS

def test_the_retirement_status_model_is_published_as_the_other_service_appends_it(self):
"""
A client generated from this document names a class after each component in it.

The partial update of a version 1 retirement refers to the
retirement-status model here, as it always has. The other service
publishes that operation's real body instead and appends the model so
that the class keeps its name, and the two describe it identically.
"""
operation = self.schema["paths"]["/api/user/v1/accounts/update_retirement_status/"]["patch"]
bodies = {content["schema"]["$ref"] for content in operation["requestBody"]["content"].values()}
assert bodies == {"#/components/schemas/PatchedUserRetirementStatus"}
assert self.schema["components"]["schemas"]["PatchedUserRetirementStatus"] == PATCHED_USER_RETIREMENT_STATUS
15 changes: 15 additions & 0 deletions cms/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.urls import path

from openedx.core.apidocs import cached_schema_view
from openedx.core.djangolib.testing.utils import unresolved_references


class CmsModuleTests(TestCase):
Expand All @@ -29,6 +30,20 @@ def test_api_docs_schema(self):
response = self.client.get('/api-docs/schema/')
assert response.status_code == 200

def test_every_reference_in_each_served_document_resolves(self):
"""
Each document this service serves defines every component it refers to.

A component appended through the settings is copied in as written, so
one pointing at a model that only another document defines leaves a
reference that a client generator refuses.
"""
for address in ('/api-docs/schema/', '/authoring-api/schema/'):
response = self.client.get(address, HTTP_ACCEPT='application/vnd.oai.openapi+json')
assert response.status_code == 200, address
unresolved = unresolved_references(response.json())
assert not unresolved, (address, unresolved)

@override_settings(
OPENAPI_CACHE_TIMEOUT=60,
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
Expand Down
2 changes: 2 additions & 0 deletions docs/docs_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@

# OpenAPI 3 security schemes, consumed by drf-spectacular. "Basic" uses the
# OpenAPI 3 http/basic form rather than Swagger 2.0's type: basic.
# Components the service settings already append are kept.
SPECTACULAR_SETTINGS["APPEND_COMPONENTS"] = { # noqa: F405
**SPECTACULAR_SETTINGS.get("APPEND_COMPONENTS", {}), # noqa: F405
"securitySchemes": {
"Basic": {
"type": "http",
Expand Down
25 changes: 19 additions & 6 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
from path import Path as path

from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
from openedx.core.djangoapps.user_api.schema_components import KEPT_COMPONENT_SCHEMAS
from openedx.core.lib.derived import Derived
from openedx.core.lib.features_setting_proxy import FeaturesProxy
from openedx.envs.common import * # pylint: disable=wildcard-import # noqa: F403
Expand Down Expand Up @@ -2179,23 +2180,35 @@

add_optional_apps(OPTIONAL_APPS, INSTALLED_APPS) # noqa: F405

###################### drf-spectacular (LMS enrollment schema) ######################
###################### drf-spectacular (LMS API schema) ######################
SPECTACULAR_SETTINGS = {
'TITLE': 'LMS Enrollment API',
'TITLE': 'LMS API',
'VERSION': '0.1.0',
'SERVE_INCLUDE_SCHEMA': False,
'PREPROCESSING_HOOKS': ['lms.lib.spectacular.lms_api_filter'],
# Mark legacy slashless enrollment addresses deprecated. The enum hook is
# drf-spectacular's default, restated because setting this key replaces
# the default list.
# Mark superseded addresses deprecated. The enum hook is drf-spectacular's
# default, restated because setting this key replaces the default list.
'POSTPROCESSING_HOOKS': [
'drf_spectacular.hooks.postprocess_schema_enums',
'lms.lib.spectacular.lms_mark_legacy_paths_deprecated',
'openedx.core.djangoapps.user_api.schema.mark_superseded_user_paths',
'openedx.core.apidocs.untag_deprecated_operations',
],
# Carried by every document built from these settings; Studio generates the
# identical model from its own description of the operation.
'APPEND_COMPONENTS': {
'schemas': dict(KEPT_COMPONENT_SCHEMAS),
},
# Pinned so a second same-named choice field cannot rename these sets; each
# names its model so it never claims a plain name another field may need.
'ENUM_NAME_OVERRIDES': {
'RetirementErasureScopeEnum': 'openedx.core.djangoapps.user_api.v2.serializers.ERASURE_SCOPE_CHOICES',
'RetirementBatchResultOutcomeEnum':
'openedx.core.djangoapps.user_api.v2.serializers.BATCH_OUTCOME_CHOICES',
},
# Stripped from each path to derive its tags and operationId, so changing
# it renames every operationId. Paths themselves are emitted in full.
'SCHEMA_PATH_PREFIX': '/api/enrollment',
'SCHEMA_PATH_PREFIX': r'/api/(enrollment|user)',
# Serve the Swagger UI and ReDoc assets from drf-spectacular-sidecar rather
# than drf-spectacular's default unpinned jsdelivr CDN URLs, keeping them
# self-hosted and version-pinned as the drf-yasg bundles were.
Expand Down
2 changes: 1 addition & 1 deletion lms/envs/devstack.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ def should_show_debug_toolbar(request): # pylint: disable=missing-function-docs
'COMPLETE',
]

###################### drf-spectacular (LMS enrollment schema) ######################
###################### drf-spectacular (LMS API schema) ######################
SPECTACULAR_SETTINGS['SERVERS'] = [ # noqa: F405
{'url': LMS_ROOT_URL, 'description': 'Local'}, # noqa: F405
]
Expand Down
2 changes: 1 addition & 1 deletion lms/envs/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ def get_env_setting(setting):
MIDDLEWARE.extend(_YAML_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) # noqa: F405


###################### drf-spectacular (LMS enrollment schema) ######################
###################### drf-spectacular (LMS API schema) ######################
SPECTACULAR_SETTINGS['SERVERS'] = [ # noqa: F405
{'url': LMS_ROOT_URL, 'description': 'Local'}, # noqa: F405
]
Expand Down
Loading
Loading