Repository navigation
feat: standardize the user accounts, profile image, verification and name-change API - #39297
Draft
Abdul-Muqadim-Arbisoft wants to merge 4 commits into
Draft
Abdul-Muqadim-Arbisoft wants to merge 4 commits into
Abdul-Muqadim-Arbisoft wants to merge 4 commits into
Conversation
The user directory, the stored-preference listings and the time-zone
reference list were the least conforming endpoints of the User API: the
only gate was the deprecated X-Edx-Api-Key header, which ignores
request.user entirely, so any holder of the key could read every user's
email address and full preference mapping. The listings were addressed by
database id, two of them put the parent key in the path instead of the
query string, the time-zone list returned an unbounded array, the stored
preference row embedded its owner's entire record (one extra query per
row), and the forum-role listing created the role it was asked about on
every GET.
Each of those is a contract change, so they land on a new version and
/api/user/v1/ is untouched and keeps serving them.
What callers get instead:
GET /api/user/v2/users/ (also ?preference_key=,
?forum_role=&course_key=)
GET /api/user/v2/users/{username}/
GET /api/user/v2/user_preferences/
GET /api/user/v2/user_preferences/{username},{key}/
GET /api/user/v2/time_zones/
Authenticated staff callers rather than an API key, the standard error
envelope, the standard pagination envelope on all three lists, filter
sets and a deterministic ordering, and the ?view= and ?fields= response
presets. The forum-role filter no longer creates a role, and an
unparsable course key is a validation error rather than a 500.
Being refused is part of the contract, so the refusals are explicit. A
deactivated staff account is turned away on both paths rather than only
one: the session authenticator already rejected it, but IsAdminUser reads
only is_staff and the JWT authenticator does not read is_active, so such
an account could read the whole directory until its token expired. These
addresses require an active user.
An unrecognised query parameter is a 400 rather than silence. A client
porting from a superseded address by changing the version segment alone
would otherwise pass a filter this version does not define, have it
ignored, and receive every row believing the page was filtered. The
allowlist is derived from the paginator, the ordering backend, the filter
set and the query serializer, so it cannot drift away from the filters it
describes.
Shared building blocks are used rather than rewritten:
StandardizedErrorMixin, DefaultPagination, IterablePaginationMixin,
ScopedQuerysetMixin with FullScopePolicy, MinimalViewMixin and project.
The two local classes each supply one predicate the library does not,
and each docstring says which.
The tests pin behaviour rather than structure: every allowed and every
refused caller with the error type asserted, value-level parity with each
superseded address, query counts that stay constant across 1, 5 and 25
rows where the superseded listing pays per row, SQL captures proving no
GET writes, and the URL literals each name reverses to.
The LMS drf-spectacular pre-processing hook is an allowlist and admitted only /api/enrollment/vN/, so everything under /api/user/ generated, routed and tested while being invisible in the published document. The new v2 addresses would have shipped undocumented and the schema gate would have reported the prefix missing with no clue as to why. The filter now admits exactly what this change owns: the v2 addresses, matched by their view package rather than by path prefix, plus the seven legacy paths they supersede, so each superseded address appears beside its replacement. It is deliberately not a /api/user/vN/ prefix. Admitting the whole prefix pulled thirty-eight further v1 operations into the document -- undocumented request bodies and internal docstrings published as client-facing text -- none of which this change owns or documents. SCHEMA_PATH_PREFIX becomes a two-branch pattern, stripped only to derive tags and operationIds. Every existing enrollment operationId was compared before and after and is unchanged, and so is every operationId, path and component of the service-wide /api-docs document: a generated client derives a module per operationId and a model per component, so a rename there deletes a module as surely as a removal does. The title follows the contents, and the three comments that named the old title follow it. Deprecation is marked where it belongs, outside the frozen modules: the existing post-processing hook gains a tuple of the seven exact legacy paths this change supersedes. Exact paths, not a prefix, so an address whose replacement nobody has written yet can never be marked -- the hook test includes neighbouring user addresses, the unversioned twins and another area's paths and asserts they are left alone. POSTPROCESSING_HOOKS is unchanged: setting it replaces drf-spectacular's defaults, so the enum hook stays restated and stays first.
…api/user/v2/
The account surface was the largest non-conforming part of the User API and the
one most callers depend on: frontend-platform's account fetch, the account and
profile MFEs, and both mobile apps all read it. Ten of its operations answered
in shapes nothing documented, refused malformed input with server faults, and
addressed one account's records through a permission that read a username from
somewhere other than the address.
Every change here is a contract change, so they land on a new version:
/api/user/v1/ is untouched and keeps serving every one of its callers.
What this version answers:
GET /api/user/v2/accounts/ (?username=, ?email=,
?lms_user_id=, ?view=,
?fields=, ?ordering=)
GET /api/user/v2/accounts/me/
GET /api/user/v2/accounts/{username}/
PATCH /api/user/v2/accounts/{username}/
POST /api/user/v2/accounts/{username}/deactivation/
POST /api/user/v2/email_searches/
POST /api/user/v2/name_changes/
POST /api/user/v2/name_changes/{username}/confirmations/
GET /api/user/v2/accounts/{username}/verification_status/
GET /api/user/v2/accounts/{username}/verifications/
A refusal is part of the contract, so the refusals are explicit. The request
body is measured against a serializer before the account service reads it, so a
wrongly typed property is a validation envelope rather than a server fault; a
failure inside the service is a server fault rather than a validation envelope.
A permission reads the username from the address and from nowhere else, and
compares it exactly, because the handler behind it selects the record by that
same value. An account that is no longer active is refused on both halves of the
default authentication pair rather than only one.
Lists answer in the standard envelope, bounded and ordered by username; a filter
that matches nothing answers an empty page rather than reporting the address as
missing, and a status that has never been established answers "none" rather than
reporting the account as missing. An email search is bounded at a hundred
addresses, which bounds its answer.
The schema describes what the code does: the superseded addresses publish the
media types they actually read, every operation publishes the codes it really
answers, and the response components carry the properties the bodies really
hold, with presets modelled rather than implied.
The tests pin behaviour rather than structure. Every allowed and every refused
caller is named with the error type asserted; value-level parity is checked
against each superseded address; query counts are measured on the earlier
endpoint first and asserted not to grow with the collection; SQL captures prove
no read writes, including writes deferred to commit; and each guard was checked
by breaking what it covers and watching the right tests fail.
…rofile_image/
The profile-image endpoints are addressed under the User API but served by a
view that belongs to the profile_images app, so the conforming version lives in
that app's own new version package and is mounted into the user version two
URLconf exactly as the earlier view is imported today. The app's existing
modules are untouched, and the image validation they own is called rather than
copied.
POST /api/user/v2/accounts/{username}/profile_image/
DELETE /api/user/v2/accounts/{username}/profile_image/
An upload is read before anything is stored, so a truncated file, metadata that
cannot be parsed, or a header declaring more pixels than the site will ever draw
are answered as the caller's error rather than as a server fault, while a
failure to store a well-formed image stays a server fault. The media types the
address reads are published, and the body is published as required.
A caller may set and clear the image on the account the address names and on no
other, staff included, since clearing an image is a change to an account rather
than a moderation tool. The permission reads the username from the address and
compares it exactly, because that is the value the stored names are built from,
so the account a caller is admitted for is always the account written to.
The tests drive real uploads: each refusal with its error type, parity against
the earlier address for the bodies both answer, and a check that a caller
holding another spelling of the owner's name is refused and stores nothing.
This was referenced Oct 23, 2026
Open
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1. Summary
This is Phase B of five (A → E) of the
[API] Userstandardization tracked on#39058, under the umbrella issue #38137.
It adds eleven conforming operations under
/api/user/v2/that supersede twelve operationsat ten
/api/user/v1/addresses — the account collection and member address, the account change,the search over email addresses, the deactivation, the two profile-image methods, the two ID
verification reads and the two name-change operations. (Eleven, not twelve, because
GET /api/user/v1/meandGET /api/user/v1/accounts/{username}are answered by one address here.)/api/user/v1/is untouched and keeps serving every one of those addresses.Why a new version rather than an in-place change. Every one of these addresses is in use by
MFEs and by operator tooling today, and conforming to the decisions below changes the contract in
ways a client can see: a bare JSON array becomes a pagination envelope; a refusal becomes the
RFC 9457 envelope;
POST …/deactivate/answers200with an account body where the conformingaddress answers
204with none; a query parameter an operation does not read is now refused ratherthan ignored. ADR 0037 forbids making those changes where a published address already answers, so
they land as a new version and the old one is marked
deprecated: truein the published schema andremoved through the DEPR process.
Decisions applied: 0025, 0026, 0027, 0028, 0029, 0030, 0031, 0032, 0033, 0034, 0036, 0037 and 0038
from
docs/decisions/,plus OEP-66 and
OEP-69.
See the checkbox list and matrix in §4 and §5 for what each one means here.
Scope and phasing
/api/user/v2/users,user_preferences,time_zones+ the shared wiringPhase A's two commits sit beneath this branch, so a diff against
mastershows both. Reviewthis PR against #39237's head; when #39237 merges, this branch rebases onto it. Nothing in this
PR changes a file Phase A added except
lms/lib/spectacular.pyand its test (§7).2. Change table
The working tree is one logical change per concern; the commits are proposed below and are made at
the point this PR opens.
feat: add /api/user/v2/ accounts and email searchesGET /accounts/,GET,PATCH /accounts/{username}/,POST /email_searches/meresolving to the caller on the member address; the 404 disguise a non-administrator gets from the searchordering,fields,viewvalidated by a serializer and published; merge-patch body validated before it is applied; RFC 9457 refusals; unknown query parameters refusedfeat: add /api/user/v2/ account deactivation and name changesPOST /accounts/{username}/deactivation/,POST /name_changes/,POST /name_changes/{username}/confirmations/204with no body for a deactivation and a confirmation;201 {"name": …}for a request; a name longer than the column that stores it refused; an unknown username answered404feat: add /api/user/v2/ profile image and verification addressesPOST,DELETE /accounts/{username}/profile_image/,GET /accounts/{username}/verification_status/,GET /accounts/{username}/verifications/images.pyapplies; the summary's members; owner-or-administrator accessorderingand a stable tie-break; an undrawable image refused400instead of raising; a summary for an account that has been through no check answered200 {"status": "none"}feat: publish the superseded user addresses in the LMS schemaSUPERSEDED_USER_PATHS— this phase's ten plus Phase A's sevendeprecated: trueby a post-processing hook3. Caller-visible changes (complete, declared)
Every difference a client can observe, from the cross-cutting review's enumeration plus the items
each repair pass added.
[T]= pinned by a test in this diff;[U]= not pinned by a test;[S]= also visible in the published schema. Nothing caller-visible is left out of this list.Cross-cutting
/api/user/v1/…address is now published in the LMS schema and stampeddeprecated: true;before this change that document carried enrollment paths only.
SCHEMA_PATH_PREFIXwidens to/api/(enrollment|user). The LMS document's title changes from "LMS Enrollment API" to"LMS API" — a client regenerated from
/lms-api/schema/is renamed with it. [T][S]from the v2 serializers. [T][S]
GET /api/user/v1/accountsandGET /api/user/v1/accounts/{username}get pinned operationIdsv1_accounts_list/v1_accounts_retrieve. These are newly published paths, so no consumerloses an import. [T][S]
/api/user/v2/<anything unrouted>answers a JSON 404 envelope instead of the service's HTMLerror page.
/api/user/v2/account/and/api/user/v2/account/{anything}/— the singularmisspelling — are answered the same way. The two addresses the authentication app serves under
that prefix (
account/registration/,account/login_session/) are unchanged, including the 301each gives without its trailing slash. [T]
type/title/status/detail/instance),where the superseded addresses answered an empty body,
{"detail": …}or{developer_message, user_message}. [T]400; the supersededaddresses ignored unknown parameters. [T]
BearerAuthentication/BearerAuthenticationAllowInactiveUseris accepted on no v2 address.[U]
openedx-platform-sdkalone, as every other SDK-tagged operation in the LMS and Studio documents is; the
usergroupwas dropped from them so a generated client carries each operation once. Phase A's five
operations carry no SDK tag and keep the
usergroup. [T][S]GET /api/user/v1/me→GET /api/user/v2/accounts/me/{"username": …}; this one answers the whole account.?fields=usernamereproduces the earlier body exactly — but not the earlier cost: 13 queriesthere against 20 here, and 20 with the selection, because the account is built and then shaped.
[T]
GET /api/user/v1/accounts→GET /api/user/v2/accounts/count, num_pages, current_page, start, next, previous, results). [T][S]?username=naming no account:404with an empty body →200with an empty page. [T]?email=and?lms_user_id=naming no account:404→200with an empty page, as?username=already did. A declared, settled difference: a filtered collection that matchednothing is an empty list, and
404here should mean "no such address" — and the siblingselector
?username=already answered200with an empty page for exactly this case. The one404the selectors keep is the retired address. Pinned bytest_a_selection_no_account_matches_is_a_page_here_and_was_a_refusal_before. [T]?page=Npast the end →404"Invalid page." [T]ORDER BY usernamewhere the superseded address answered inwhatever order the database returned the rows. A new
orderingparameter acceptsusername/-username/ empty and refuses anything else with400, naming the values it accepts. [T][S]fields=andview=minimal;view=sharedkept. Naming a property this caller may not beanswered with is refused with
400. [T][S]404body{error_msg, can_cancel_retirement, retirement_id}becomes theenvelope with
detailplus those last two as extension members, undertype: …/user/retired-account. [T]GET /api/user/v1/accounts/{username}→GET /api/user/v2/accounts/{username}/404gains a body. [T]meresolves to the caller. A user whose username is literallymeis reachable through?username=meon the collection. [T]email_searchesis reachable through?username=email_searches; thesearch itself is a top-level collection (item 26). [T]
spelled in another case is answered with the account holding that name. [T] — the test
simulates that collation; the test database compares exactly, so this has not been run
against MySQL.
page/page_size:GET /accounts/{username}/?page=9and
PATCH …?page=2answered200with the parameter ignored and now answer400; the refusaloffers
fields, format, view. [T]404descriptions name the cause that occurs rather than "no such user" —schema text only. [S]
learner sees, and the account reduced to what identifies its owner. Schema text only; the
example values are invented, not captured bodies. [S]
PATCH /api/user/v1/accounts/{username}→PATCH /api/user/v2/accounts/{username}/application/merge-patch+jsononly; another media type is refused with415. [T][S]{"field_errors": {f: {developer_message, user_message}}}→ the envelopewith
errors: {f: [user_message]}.developer_messageis no longer exposed. [T]answer
400naming the property where they used to answer500:nameas null/number/list/object/boolean,
bioas null,emailas a number or boolean,countryas a list or object,phone_numberas a number/list/object/boolean, and a social link sent without its address. [T]400where the value used to be coerced and stored:bio,city,goals,mailing_addresssent as a number, andaccount_privacysent as a number, list,object or boolean. Before,
{"bio": 5}stored"5". [T]Not a valid string.,A valid integer is required.) instead ofThis value is invalid.. [T]500 internalwith one stable sentence andthe specifics in the log, where it used to read as
400 validation. A database failure is nolonger reported to the caller as invalid input. [T]
ALLOW_EMAIL_ADDRESS_CHANGEoff answers400namingemailwith this endpoint'ssentence instead of a
400carrying the operator-facing one. [T]account_privacyis published as nullable, which it already behaved as. [S]{"error": "Too many requests"}becomes the envelope, still429. [T]400 non_field_errors. [T](
400) before the address it names is read or written — so a change to another accountanswers
400with?fields=emailand403without it. The refusal is identical at a name noaccount holds, and no account is read or changed to produce it. [T]
POST /api/user/v1/accounts/search_emails→POST /api/user/v2/email_searches/usernamewhere the superseded address answered in database order. [U]is refused
400witherrors: {emails: ["Ensure this field has no more than 100 elements."]},where the superseded POST took as many addresses as a body could carry and read them in one
query. 100 is the largest page a caller may ask for, so no search can find more accounts than
one page will hold, and the bound is published on the request (
maxItems: 100). With nopage_sizethe answer is the first ten accounts and a link to the rest, where the supersededPOST answered a bare unbounded array. Paging costs the search one read more than the single
unbounded answer did, whatever the addresses asked about. [T][S]
nextlink again; fetching that linkwith
GETis405. A page the search does not have is404. [T][S]emailsmember:{developer_message, user_message}→errors: {emails: ["This field is required."]}.emailsthat is not a list is refused400rather than passed to the query.[T]
404) inboth versions; the body becomes the envelope. [T]
403a credential whose account is no longer active receives is now published. [T][S]GETon the collection is405with a body. [T]POST …/deactivate/→POST /api/user/v2/accounts/{username}/deactivation/200with the whole account body →204with no body, which costs six queries fewer. [T][S]404rather than raising. [T]meis read as a stored username here, exactly as at the superseded address: the operationnever disables the caller's own account by that name. [T]
formatonly, where it offeredformat, page, page_size. [T]nothing from the body, so it accepts what the superseded address accepted rather than answering
415. [T]POST …/accounts/name_change/→POST /api/user/v2/name_changes/201with an empty body →201 {"name": …}. [T][S]nameis capped at 255 characters and refused400above it; the superseded address acceptedany length and would have failed at the column. [T]
new_namebecomesname, and "The profile name given was identical to thecurrent name." becomes "The profile already carries this name." [T]
application/json,application/x-www-form-urlencodedandmultipart/form-data— what the superseded address read. All three are published. [T][S]400withtype: …/malformed-body. [T]GETon the collection is405with a body. [T]POST …/name_change/{username}/confirm/→POST /api/user/v2/name_changes/{username}/confirmations/404rather than raising. [T]issued before its account was disabled is refused
403here. [T]403gains a body. [T]menames an account holding that name at this address, as the published parameter says. [T]POST|DELETE …/accounts/{username}/image→…/accounts/{username}/profile_image/{developer_message, user_message}→errors: {file: […]}. The rawstr(ImageValidationError)is no longer exposed. [T]400witha stable message, where it used to raise; the library error is logged. The same for bytes that
stop early, metadata that does not parse, and a header declaring more pixels than the site
draws. Nothing is stored in any of those cases. [T]
Content-Dispositionfilename is refused400. [T]DELETEfor an account that carries no profile is a404envelope. [T]field changes neither who may call nor where the sizes are stored, and the name is read as it is
stored. Administrators are refused another account's image, as at the superseded address. [T]
POST …/imagegains four media types (image/gif,image/jpeg,image/pjpeg,image/png) besidemultipart/form-data; it published no requestbody before this change. [S]
GET …/verification_status/andGET …/verifications/200 {"is_verified": false, "status": "none", "expiration_datetime": ""}atverification_status/, and an empty page atverifications/.404at these addresses nowmeans one thing: no account holds that name. A declared, settled difference, on the same
reasoning as item 13: one status code was carrying two meanings, and the sibling addresses
(
/verifications/,/accounts/me/) already answered200for exactly the same account. Sixtests pin it, and
test_a_user_who_has_never_been_checked_is_told_apart_from_one_who_does_not_existis the onethat states the distinction. [T]
orderingparameter (updated_at/-updated_at/ empty, default-updated_at), and tiesare broken by
receipt_id, so a page is stable where equalupdated_atrows were left to thesort. [T][S]
an alternative. [T]
still active: a credential issued before its account was disabled is refused
403at anotheruser's status and attempts, and is also refused the
?email=/?lms_user_id=selectors on theaccount list. Its own records, and accounts it names by username, are unaffected. [T]
expiration_datetimeis published as a string constrained toMM/DD/YYYYor empty, describedas the calendar day; an attempt's
statusdocuments all seven values the tables hold;typesays a check carried out by an installed verification service is reported as
SSO. Schema textonly. [S]
mebeneath an accountmemeans three things in this version, and all three are documented in the publishedparameters and pinned by tests: it resolves to the caller at
/accounts/me/; it is read as astored name at
/accounts/{username}/deactivation/and at/name_changes/{username}/confirmations/; and at/accounts/me/verification_status/,/accounts/me/verifications/and/accounts/me/profile_image/it names an account that holdsthat name — a learner sending it is refused
403, an administrator sending it is told noaccount holds that name, and the user actually named
meis served their own records. Thisis what ships, and it is deliberate: making
meresolve beneath an account would store aprofile image under the literal name
me(the mutation that tried it showed exactly that), andresolving it at the deactivation would let a privileged caller disable their own account by
addressing one named
me— which is also how the superseded address reads it. A client thatlearned
mefrom the member address meets a refusal one segment down, which is why all threemeanings are in the published
usernameparameters. [T][S]Naming
(
verifications,confirmations,name_changes,email_searches) and singular where itnames the one thing every account has (
profile_image,verification_status,deactivation).4. ADRs applied
publishes; response components are held to the bodies the endpoints return.
permission_classeson every view; every legacy check survives and twoare added; deny paths tested (§10).
media types each address reads, the refusals each answers, and the parameters each reads.
APIViewswith the reason in their docstrings; record selection lives in
selectors.py.ParseError/MethodNotAllowed/NotAcceptable/UnsupportedMediaTypesubclasses registered undertheir natural slugs.
captureOnCommitCallbacks(execute=True)on both sides of the secondary-email switch; the one write is the platform provisioning an account
on first use of a token, declared and pinned.
GET /api/user/v1/meis merged into the account member address,where
?fields=usernamereproduces the earlier body.search answer 10 rows by default and never more than 100, each pinned and mutation-checked. The
search is bounded on both sides — its request names at most 100 addresses, the same number as the
largest page, so the answer cannot outgrow a page a caller can read.
orderingand every selector validated by a serializer and publishedwith its allow-list, default and empty-value meaning; an unknown value is refused, not ignored.
serving the other five make one declared deviation, substituting
SessionAuthenticationAllowInactiveUserfor the default session class, with the behaviouralreason as a comment on the line above it.
feature flags to a frontend; they all read or change one user's own data.
and changed through the account in the shape the site stored each value in;
field_valueispublished as a JSON field.
v2package and a newprofile_images/v2package; nopre-existing version directory is touched; the versions gate reports
added in old dirs: 0.path segment,
reverse()literals and same-view resolution pinned for every legacy/conformingpair. The
urlsgate is red (2 FAIL, 1 WARN) and that is declared, not hidden: the threefindings are all on Phase A's deliberately unnamed JSON-404 fallback route (
api/user/v2/{_}.),which is not an addressable resource. All fifteen real addresses pass every machine-checkable rule
— see §11.
§10; the two ORM-backed sibling lists in the same module declare
FullScopePolicy.snake_case bodies and addresses, one published document per service.
5. Compliance matrix
AccountUpdateSerializervalidates the merge-patch body before the account service sees it;AccountQuerySerializervalidates the query string; the published components are checked against real bodies, and the caller's own account is held to its component in both directions, so a property the component promises and the service stops emitting fails the suiteAccountChangeBodyShapeTests(7);test_published_shapes_accept_every_body_the_endpoints_return;test_an_account_holding_a_value_for_every_property_matches_its_component;test_a_change_body_built_from_what_the_document_publishes_is_one_the_endpoint_applies; mutation: removingpending_name_changefrom the component fails 6 schema testsif request.user.is_staffleft; the administrator half of each owner-or-administrator pair also requires an active account; the address is the only identifier the image and verification permissions readIsStaffOrAbsent.has_permission → True→ 2 failures;IsAddressedUserrelaxed → 5 failures;IsActiveUsermade a no-op → 6 failurestest_each_superseded_body_is_published_in_the_media_types_that_address_reads;test_a_refusal_that_does_not_depend_on_the_resource_reads_the_same_at_every_address;test_the_profile_image_publishes_every_media_type_it_reads_the_image_in;test_error_responses_reference_the_shared_envelope;test_schema.py64 passedAccountViewSet,EmailSearchViewSet,NameChangeViewSet,IDVerificationViewSeton the router;AccountDeactivationViewandIDVerificationStatusViewareAPIViews because a deactivation is a non-resource operation and a status is one computed summary, both stated in their docstrings; selection is inselectors.pytest_the_hand_routed_addresses_resolve_to_the_views_that_serve_them;test_addresses_resolve_to_the_version_two_viewsets;test_selectors.py(14)StandardizedErrorMixinon every view; this version registers only its ownParseError/MethodNotAllowed/NotAcceptable/UnsupportedMediaTypesubclasses, so the process-global catalog is not re-pointed; a client fault is a400naming the property, work that failed is a500 internalwith one stable sentence and the specifics in the logAccountChangeFailureTests(5);assert_error_envelopeused on every refusal path;v2/errors.pyregistrations pinned bytest_schema.pycaptureOnCommitCallbacks(execute=True)and run on both sides of the secondary-email switch, by session and by token, as owner and as administrator; the one write — the platform provisioning an account the first time a token names it — is pinned with its exact table list rather than excludedAccountReadsDoNotWriteTests+…WithARecoveryAddressTests(21 each);VerificationReadsDoNotWriteTests;TokenProvisionedAccountTests(3)/api/user/v1/meis answered by the account member address, where?fields=usernamereproduces the earlier body, so one address serves what two didtest_the_callers_own_address_answers_with_the_whole_account;test_email_search_paritymaxItemson the requestAccountPageSizeTests(4),VerificationPageSizeTests(2),PageSizeCapTests;EmailSearchTests::test_a_search_is_answered_a_page_at_a_time_when_the_caller_asks_for_no_size,::test_a_search_may_name_as_many_addresses_as_the_largest_page_carries,::test_a_search_naming_more_addresses_than_that_is_refused;test_the_search_publishes_the_addresses_it_reads_and_the_page_it_answers_with; mutations:page_size = 3andmax_page_size = 100000each go red; lifting theemailsbound turns 3 tests red; putting the answer back in one page turns 6 redorderingpublished with its values, its default and the meaning of an empty value;?username=,?email=,?lms_user_id=validated by the serializer that publishes them; an unknown parameter is refusedtest_account_list_publishes_its_selectors;test_the_selectors_are_published_from_the_serializer_that_validates_them;test_a_property_the_list_does_not_sort_by_is_refused;test_a_sort_the_list_does_not_offer_is_refusedauthentication_classes;AccountViewSet(3 operations) andProfileImageView(2) declare(DefaultJwtAuthentication, SessionAuthenticationAllowInactiveUser)— the token half is the platform's own class, so these requests are still recorded as using the default authentication, and the session half is the one deviation, so a caller who has not yet confirmed their email address can still read and change their own account and set their image. The reason is a comment on the line abovetest_a_token_is_read_by_the_class_the_platform_declares;test_a_deactivation_authenticates_the_way_the_platform_does;test_what_is_recorded_about_an_account_authenticates_the_way_the_platform_does; mutation: an authentication declaration planted on the new viewset goes redfield_valueis published as a JSON fieldExtendedProfileConfiguredTests(4) andExtendedProfileUnconfiguredTests;test_a_field_the_site_does_not_collect_is_left_out_of_the_accountuser_api/v2/andprofile_images/v2/;profile_images/{views,urls,images,exceptions}.pyare byte-identical tomasterand the v2 view calls the frozen validation rather than copying it; the one address of this unreleased version that had to move did so before publicationPASS(files in new version dirs: 29, other files: 7, added in old dirs: 0);git diff origin/master -- openedx/core/djangoapps/profile_images/is empty for the four frozen files; 14 protected paths re-verified per pathreverse()literal, its route name, its same-view resolution and its converter refusals pinned; the search moved out of the member slot before publicationtest_urls.py(34),test_url_mount.py(4),test_an_account_named_after_another_collection_is_served_at_its_own_address; urls gateFAIL (2 FAIL, 1 WARN)— all three on the unnamed fallback, §11usernames_among); there is no view-levelget_querysetover the resource's own table, so there is noscoping_policyto declare, and the docstring says so; caller scoping is the permission pair, and field visibility stays in the account servicetest_selectors.py(14); the three-layer table in §10; mutation: the selector gate removed goes redtest_urls.py,test_schema.py,test_views.py6. Backward compatibility
/api/user/v1/is untouched. No v1 view, serializer, URL or test is in this diff. The versionsgate reports
added in old dirs: 0, and the fourteen protected paths were re-verified per path(
git diff+git ls-files --otherseach): zero changed lines, zero untracked files.Legacy addresses still resolve. The urls gate walked 3,036 routes on the base and 3,051 on this
branch: fifteen new API routes, none removed, none renamed.
The published schema only adds.
schema_diff.py compare: 0 BREAKING, 54 WARN, 35 INFO. All35 INFO are "operation added". Every enrollment operationId survives the widened
SCHEMA_PATH_PREFIX, pinned bytest_enrollment_operation_ids_survive_the_widened_prefix.Record bodies are identical.
INTENTIONAL_DIFFERENCESintest_account_parity.pyis, verbatim:Six empty lists: for the account list, the member address, a shared account, another learner's
account, a changed account and an email search, the two versions answer the same record body
through the same fixtures in the same session. The differences a client sees are the envelope
around the records and the status codes, and each one is listed in §3 with its own named test —
not normalised away by the harness.
Legacy tests. The
old-testsgate is red and blind here, and it is not the compatibilityevidence. It collects
openedx/core/djangoapps/user_api/tests/only — none of the four surfacesthis phase supersedes — and its 7 failures and 16 errors are
pymongoconnection timeouts in thiscontainer. The real measurement is per surface, run base-vs-branch in the same image:
origin/master)user_api/accounts/profile_images/verification_api/user_api/preferences/ModuleStoreTestCasemongo teardown patternThis proves that no legacy test which passes on
masterfails on this branch. It does notprove that the mongo-dependent tests pass at all — only CI can say that.
Import paths. Nothing moved, so no re-export is needed.
profile_images/v2/views.pyimportsvalidate_uploaded_image,create_profile_images,remove_profile_imagesandIMAGE_TYPESfromthe frozen
profile_images/images.py.7. In-place edits
Two files outside a version directory are edited by Phase B; both are the schema wiring Phase A
introduced, and the edits are strictly opt-in additions.
lms/lib/spectacular.pySUPERSEDED_USER_PATHSextended from Phase A's seven to seventeen;superseded_bodies()added so each superseded operation publishes the body it reads and answers;PUBLISHED_USER_VIEW_MODULESextended; oneRENAMED_OPERATION_IDSentry pinning an operationId the generator would otherwise hand out by registration order. No v1 code is imported into a handler path; nothing is removed.test_only_superseded_addresses_are_marked_deprecated,LmsMarkLegacyPathsDeprecatedTest,test_every_pinned_name_replaces_one_a_document_deriveslms/lib/tests/test_spectacular.pyPhase A's own in-place edits (
lms/envs/common.py,devstack.py,production.py,lms/urls.py,openedx/core/apidocs.py) are in #39237 and are not re-litigated here; they appear in a diffagainst
masteronly because Phase A's commits sit beneath this branch.The superseded views are not among the files edited. They carry no deprecation notice, no
response header and no note in code; the only
deprecatedtext in this diff is the post-processinghook, its tests, and a test asserting no
Deprecationheader is sent.8. Error-format decision
Versioned, not in place. The RFC 9457 envelope is applied to
/api/user/v2/only; every/api/user/v1/refusal body is exactly what it was. The client evidence is that the supersededbodies are read today in three different shapes — an empty body,
{"detail": …}, and{developer_message, user_message}— by MFEs and operator tooling; rewriting them in place wouldbreak those readers on an address they already depend on.
Within v2 the envelope comes from
StandardizedErrorMixininedx-drf-extensions; this versionregisters error types only for its own
ParseError,MethodNotAllowed,NotAcceptableandUnsupportedMediaTypesubclasses, so the process-global catalog other apps share is notre-pointed, and
CatalogedRequestErrorsMixinsubstitutes them only inside the views that mix it in.9. Deprecations
The twelve
/api/user/v1/operations listed in §2 are superseded by the addresses above and markeddeprecated: truein the published schema. The superseded views are unchanged: no responseheader, and no deprecation notes in code.
Tracked on the FC-0118 umbrella DEPR ticket,
#39128, which removes them one named
release after the release this ships in.
Phase A's entry on that ticket covers the directory listings only, so the account family is not
yet on it. A ten-row entry — one row per superseded address, each naming its replacement and
#39058; two of the rows pair the two
methods served at one address — is drafted and is posted when this PR opens. It mirrors
SUPERSEDED_USER_PATHSexactly; in particular it does not claim/api/profile_images/v1/{username}/{upload,remove}, which this phase neither serves nor marks.10. Authorization
Every check the superseded handlers made survives; two are added. Nothing is flattened — the
cross-cutting review verified this row by row.
GET /accounts/,GET,PATCH /accounts/{username}/IsAuthenticated,CanGetAccountInfo,CanSelectAccountsByContactPOST /email_searches/IsAuthenticated,IsActiveUser,CanGetAccountInfo,IsStaffOrAbsentPOST /accounts/{username}/deactivation/IsAuthenticated,IsActiveUser,CanDeactivateUserPOST /name_changes/IsAuthenticatedPOST /name_changes/{username}/confirmations/IsAuthenticated,IsActiveUser,IsStaff(on the action)GET …/verification_status/,GET …/verifications/IsAuthenticated,(IsStaff & IsActiveUser) | IsAddressedUserPOST,DELETE …/profile_image/IsAuthenticated,IsAddressedUserIsAddressedUserreads the address and nothing else, and compares the name as it is stored: theaddress names one account, and a name spelled differently is a different name. A username supplied
in the query string or in a form field is not an identifier these operations read — it neither
admits a caller nor moves where an image is stored.
Three layers on the account surface (OEP-66)
usernames_among(names, ordering)— the accounts holding the names given, orderedv2/selectors.pyv2/permissions.py,AccountViewSet._selected_names?fields=is measured against what a response of this kind can carry for this calleraccounts/api.py(service),AccountViewSet.shapeable_fieldsDeny-path tests
401authnwith a body403; the account is unchanged403?email=/?lms_user_id=on the account list403; also for their own address403; its own records and accounts it names by username are unaffected403, and still403whatever the query string carries403403; nothing stored, the flag unchanged403404— there is nothing at this address403401403403403404not-found, and nothing is writtenme/accounts/me/{verification_status,verifications,profile_image}/403/404/200with their own recordsNamed tests:
test_a_learner_may_not_change_another_account,test_a_staff_caller_may_not_change_another_account_either,test_a_learner_may_not_select_by_email_address,test_a_learner_may_not_select_by_identifier,test_a_learner_may_not_select_their_own_account_by_email_address_either,test_the_selectors_a_learner_may_not_use_are_the_ones_the_privilege_is_asked_for,test_a_token_held_by_a_disabled_staff_account_may_not_select_accounts_by_contact,test_a_disabled_staff_account_still_reads_the_accounts_it_names,test_a_token_held_by_a_disabled_staff_account_may_not_read_another_users_records,test_a_disabled_staff_account_still_reads_its_own_records,test_a_learner_may_not_read_another_users_status,test_a_learner_may_not_read_another_users_attempts,test_a_refused_read_stays_refused_whatever_the_query_string_carries,test_a_caller_named_for_the_address_in_case_alone_may_not_read_the_status,test_a_caller_named_for_the_address_in_case_alone_may_not_set_the_image,test_a_caller_named_for_the_address_in_case_alone_may_not_clear_the_image,test_a_learner_may_not_set_another_learners_image,test_a_staff_caller_may_not_set_another_account_s_image_either,test_a_refused_upload_stays_refused_whatever_else_the_request_carries,test_a_name_sent_with_the_image_does_not_move_where_it_is_stored,test_a_learner_is_told_there_is_nothing_at_this_address,test_a_token_held_by_a_deactivated_staff_account_is_refused,test_a_session_whose_owner_has_not_confirmed_their_address_identifies_nobody_here,test_the_user_who_asked_may_not_confirm_their_own_request,test_another_learner_may_not_confirm_a_request_either,test_a_learner_sending_the_collections_own_name_is_refused,test_a_staff_caller_sending_it_is_told_no_account_holds_that_name,test_an_image_is_not_set_at_the_collections_own_name,test_an_image_is_not_cleared_at_the_collections_own_name,test_the_account_holding_that_name_is_served_at_it,test_the_reserved_identifier_is_read_as_a_stored_name_here.Each of these can fail. Three runtime mutations, each green before it was planted and restored
afterwards: relaxing
IsStaffOrAbsent→ 2 failures; relaxing the name comparison inIsAddressedUser→ 5 failures; makingIsActiveUsera no-op → 6 failures.11. Gate report
run_gates.sh, final run on the rebased tree (origin/master@7f01c42157):files in new version dirs: 29 other files: 7 added in old dirs: 0 allowed: 0. The gate's--allowarguments are mechanically inert here and it does not echo its resolved protect list, so the per-path evidence is agit diffand agit ls-files --otherson each of the fourteen protected paths: all zeroH3onuser_api/v2/paginators.py:6—UserApiPagination(DefaultPagination). Justified and kept: the subclass exists to restate a response schema the shared primitive under-declares — the three page-position members a generated client would otherwise not know about — and its docstring gives that reason and nothing else204); 44 "could not resolve authenticator", the library gap in §12; 5 unresolved type hints in frozen v1 serializers now pulled into the document; 1 "unable to guess serializer" on the frozen v1AccountViewSetapi/user/v2/{_}.:FAIL R6missing trailing slash,FAIL R8identifier directly under the version,WARN R11no URL name. That route is Phase A's JSON-404 catch-all — deliberately unnamed, not an addressable resource, and the only shape that can answer "unknown collection under this version" (six candidates were measured). TheWARN R2onaccounts/email_searches/that the previous run carried is gone: that address moved to the top-levelPOST /api/user/v2/email_searches/before publication. Blind on: R6/R8/R11 for a fallback route — reported, not suppressedpymongoconnection timeout in this container. The gate collectsuser_api/tests/only, which is none of the four surfaces this phase supersedes. It is not the compatibility evidence; §6's per-surface base-vs-branch table isOne gate must be re-run before merge:
schema. The table above was measured before the emailsearch was bounded, which adds
maxItems: 100to the publishedEmailSearchrequest and rewordsthat operation's description. Both are additions to a path this PR introduces, so the expected
result is unchanged — but it has not been re-measured, and the table does not claim it was. The
other gates were re-checked after that change:
hygienere-run (PASS, 0 FAIL, the same singleH3 WARN), the suite re-run in full (903 passed), and
versions,urlsandold-testscannothave moved — no file left or entered a version directory, no route changed, and no v1 file is in
the diff.
Mirrored locally beyond the gates:
pylint --rcfile=pylintrcover every touched module (nomessages, re-run after the change above), and a
drf-spectaculargeneration plus read-back of thegenerated operations.
isort --check-onlyis not a signal in this repo — there is no isort configuration, and thedefault profile flags untouched files on
mastertoo.Tests
903 passed. The same suite was 728 before this review round's fourteen repair passes. Per
module, from the final gate run plus the three tests the search bound added:
user_api/v2/tests/test_account_views.pyuser_api/v2/tests/test_verification_views.pyuser_api/v2/tests/test_schema.pyprofile_images/v2/tests/test_views.pyuser_api/v2/tests/test_name_change_views.pyuser_api/v2/tests/test_urls.pyuser_api/v2/tests/test_account_parity.pyuser_api/v2/tests/test_account_query_counts.pylms/lib/tests/test_spectacular.pyuser_api/v2/tests/test_selectors.pyuser_api/v2/tests/test_url_mount.pyprofile_images/suite and enrollments v2, re-run unchangedSeven of those modules are new in this phase (472 tests);
test_schema.py,test_urls.pyandtest_spectacular.pyare extended.Query cost is asserted exactly, not bounded:
test_account_query_counts.pypins that a page costs aconstant part plus a per-account part and nothing for the accounts it leaves out, and the email
search's cost is one constant across 1, 5 and 25 rows — a real N+1 assertion. Paging the
search costs one read more than the single unbounded answer did, and that one read is the number of
rows there are to page through, not a read per row.
Where a gate could not see the change, and what was produced instead
H8grepsget/list/retrievebodies, so it cannot see a write made through theverification or account service. The zero-write evidence is the runtime SQL captures
(
CaptureQueriesContextinsidecaptureOnCommitCallbacks(execute=True)), which run on eachdata-bearing branch, on both sides of the secondary-email switch, and under both ways of
identifying the caller.
schema_diff.py comparereads a path-key rename as a removal, and the attempts collectionchanged from a list generic to a router-registered viewset mid-review. The evidence that nothing
moved is a read-back of the generated operation object: same
operationId, tags, parameters(
orderingwith its enum and default,page,page_size,usernamewith its pattern),responses and component name.
the suite went red before restoring it — more than fifty mutations across the fourteen passes,
each recorded with the tests that failed. Examples:
page_size = 3andmax_page_size = 100000on the paginator, a retyped account property, the selector gate removed, an authentication
declaration planted on a viewset, the
mename admitted by the address permission (which showedan image would otherwise be stored under the literal name
me), the account-service failureclassified back to
400, and — for the search bound — theemailsbound lifted (3 tests red: therefusal, the version-to-version contrast and the published
maxItems) and the answer put backinto one page (6 red: the three page assertions and all three query-cost measurements).
12. Follow-ups
Library-first — three items, none patched locally
None of these is worked around in this diff; the brief forbids a local copy of a shared primitive,
so each is named here for an upstream issue against
edx-drf-extensions(pinned at 10.9.0) or DRF.flatten_detailputs a Python repr intodetail. A nested validation refusal arrives as{"social_links": ["{'social_link': [ErrorDetail(string='This field is required.', code='required')]}"]}— the
errorsmember beside it is correct, butdetailcarries an internal representation in afield the schema types as human-readable prose. Produced by the shared error mixin, not by this
diff. Two tests quote the reason out of the line rather than matching it whole, and say why.
OpenApiAuthenticationExtensionfor the platform's authenticators. 44 of this run's newgeneration warnings are "could not resolve authenticator", and the consequence is that the
published document describes no security scheme for these operations. The same pattern is
already visible on the Enrollment v2 baseline. A
securityblock needs the library to registerextensions for its JWT and session classes.
RecursionError.rest_framework/parsers.py:63-68catchesValueErroronly, so a deeply nested JSON body raises rather than being refused. Version 1 answers 500 for
the same body, so this is not a regression — named upstream rather than patched here.
Next-version candidates (ADR 0037)
get_account_settingsdecides whole-account-versus-shared from the staff flag alone, so acredential whose account was disabled still reads a whole account at the member address. It
cannot be changed from v2 without changing a parity-measured response body; it belongs in the
shared account service.
_whole_accounts_are_readableand_addressed_namescompare usernames exactly. On a deploymentwhose database compares without regard to case, an owner reading their own account at a
differently-spelled address is served the shared-property shape rather than the whole account.
This withholds data rather than widening it; making access depend on a collation is a larger
decision than this PR.
SSO. Documented, notchanged — parity with the superseded body.
cityis changeable through the account but is carried by no account response, because it is notin the service's field set.
404for anaccount carrying no profile happens after the sizes are written. The superseded view has the same
order.
PATCH /api/user/v1/accounts/{username}does not list415among its publishedresponses, although the frozen handler answers it. Adding it would mean editing the description of
a frozen operation; the media types it publishes were narrowed to the one it reads, which is
what a generated client needs.
Mount scope
/api/user/v2/is LMS-only. Studio keeps serving the v1 account addresses and its clients arenot asked to move in this phase.
SDK
The eleven operations are tagged
openedx-platform-sdk, so the generated client libraries pick themup; an SDK regeneration PR follows this one.
Phase A
Two small items are queued for #39237's next rebase (it must rebase anyway): a task-narration
comment in
test_parity.py, and a pinnedoperation_idon the preferences member address whose pinis better dropped so the document derives its own form, as the seven paired-key addresses the LMS
already publishes do.