Skip to content

feat: standardize the user accounts, profile image, verification and name-change API - #39297

Draft
Abdul-Muqadim-Arbisoft wants to merge 4 commits into
openedx:masterfrom
edly-io:feat/api-adr-user-v2-phase-b
Draft

Abdul-Muqadim-Arbisoft wants to merge 4 commits into
openedx:masterfrom
edly-io:feat/api-adr-user-v2-phase-b

Conversation

@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor

1. Summary

This is Phase B of five (A → E) of the [API] User standardization tracked on
#39058, under the umbrella issue #38137.

It adds eleven conforming operations under /api/user/v2/ that supersede twelve operations
at 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/me and GET /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/ answers 200 with an account body where the conforming
address answers 204 with none; a query parameter an operation does not read is now refused rather
than 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: true in the published schema and
removed 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

Phase Surface Status
A /api/user/v2/ users, user_preferences, time_zones + the shared wiring PR #39237, open as a draft
B accounts, email searches, deactivation, profile image, ID verification, name changes this PR
C, D, E the remaining user surfaces separate PRs; E is not approved to build

Phase A's two commits sit beneath this branch, so a diff against master shows both. Review
this 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.py and 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.

Concern (one commit) Operations Legacy behaviour kept New behaviour
feat: add /api/user/v2/ accounts and email searches GET /accounts/, GET,PATCH /accounts/{username}/, POST /email_searches/ the account record itself — every property, every value, the field-level visibility the account service computes per caller; me resolving to the caller on the member address; the 404 disguise a non-administrator gets from the search pagination envelope; ordering, fields, view validated by a serializer and published; merge-patch body validated before it is applied; RFC 9457 refusals; unknown query parameters refused
feat: add /api/user/v2/ account deactivation and name changes POST /accounts/{username}/deactivation/, POST /name_changes/, POST /name_changes/{username}/confirmations/ who may call each one; the name a change requests and the validation applied to it; the media types the name change reads 204 with 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 answered 404
feat: add /api/user/v2/ profile image and verification addresses POST,DELETE /accounts/{username}/profile_image/, GET /accounts/{username}/verification_status/, GET /accounts/{username}/verifications/ the stored sizes and the validation the frozen images.py applies; the summary's members; owner-or-administrator access the attempts in a pagination envelope with a published ordering and a stable tie-break; an undrawable image refused 400 instead of raising; a summary for an account that has been through no check answered 200 {"status": "none"}
feat: publish the superseded user addresses in the LMS schema the seventeen paths in SUPERSEDED_USER_PATHS — this phase's ten plus Phase A's seven the v1 handlers, byte for byte each superseded path published with the body it reads and answers, and stamped deprecated: true by a post-processing hook

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

  1. Every /api/user/v1/… address is now published in the LMS schema and stamped deprecated: true;
    before this change that document carried enrollment paths only. SCHEMA_PATH_PREFIX widens 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]
  2. The v1 operations that previously published no request or response body now publish one, borrowed
    from the v2 serializers. [T][S]
  3. GET /api/user/v1/accounts and GET /api/user/v1/accounts/{username} get pinned operationIds
    v1_accounts_list / v1_accounts_retrieve. These are newly published paths, so no consumer
    loses an import. [T][S]
  4. /api/user/v2/<anything unrouted> answers a JSON 404 envelope instead of the service's HTML
    error page. /api/user/v2/account/ and /api/user/v2/account/{anything}/ — the singular
    misspelling — are answered the same way. The two addresses the authentication app serves under
    that prefix (account/registration/, account/login_session/) are unchanged, including the 301
    each gives without its trailing slash. [T]
  5. Every v2 refusal body is the RFC 9457 envelope (type/title/status/detail/instance),
    where the superseded addresses answered an empty body, {"detail": …} or
    {developer_message, user_message}. [T]
  6. Every v2 operation refuses a query parameter it does not read with 400; the superseded
    addresses ignored unknown parameters. [T]
  7. BearerAuthentication / BearerAuthenticationAllowInactiveUser is accepted on no v2 address.
    [U]
  8. The eleven operations a client library is generated from are tagged openedx-platform-sdk
    alone
    , as every other SDK-tagged operation in the LMS and Studio documents is; the user group
    was dropped from them so a generated client carries each operation once. Phase A's five
    operations carry no SDK tag and keep the user group. [T][S]

GET /api/user/v1/me → GET /api/user/v2/accounts/me/

  1. The superseded address answered {"username": …}; this one answers the whole account.
    ?fields=username reproduces the earlier body exactly — but not the earlier cost: 13 queries
    there 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/

  1. Bare JSON array → pagination envelope (count, num_pages, current_page, start, next, previous, results). [T][S]
  2. Default page size 10, maximum 100. [T]
  3. ?username= naming no account: 404 with an empty body → 200 with an empty page. [T]
  4. ?email= and ?lms_user_id= naming no account: 404 → 200 with an empty page, as
    ?username= already did. A declared, settled difference: a filtered collection that matched
    nothing is an empty list, and 404 here should mean "no such address" — and the sibling
    selector ?username= already answered 200 with an empty page for exactly this case. The one
    404 the selectors keep is the retired address. Pinned by
    test_a_selection_no_account_matches_is_a_page_here_and_was_a_refusal_before. [T]
  5. ?page=N past the end → 404 "Invalid page." [T]
  6. Default ordering is now ORDER BY username where the superseded address answered in
    whatever order the database returned the rows. A new ordering parameter accepts username /
    -username / empty and refuses anything else with 400, naming the values it accepts. [T][S]
  7. New fields= and view=minimal; view=shared kept. Naming a property this caller may not be
    answered with is refused with 400. [T][S]
  8. The retired-email 404 body {error_msg, can_cancel_retirement, retirement_id} becomes the
    envelope with detail plus those last two as extension members, under
    type: …/user/retired-account. [T]

GET /api/user/v1/accounts/{username} → GET /api/user/v2/accounts/{username}/

  1. A trailing slash is required. The 404 gains a body. [T]
  2. me resolves to the caller. A user whose username is literally me is reachable through
    ?username=me on the collection. [T]
  3. A user whose username is email_searches is reachable through ?username=email_searches; the
    search itself is a top-level collection (item 26). [T]
  4. On a deployment whose database compares usernames without regard to case, a member address
    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.
  5. A member address no longer accepts page / page_size: GET /accounts/{username}/?page=9
    and PATCH …?page=2 answered 200 with the parameter ignored and now answer 400; the refusal
    offers fields, format, view. [T]
  6. Three published 404 descriptions name the cause that occurs rather than "no such user" —
    schema text only. [S]
  7. The member address publishes three response examples: the whole account, the account another
    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}/

  1. The operation reads application/merge-patch+json only; another media type is refused with
    415. [T][S]
  2. Validation refusals: {"field_errors": {f: {developer_message, user_message}}} → the envelope
    with errors: {f: [user_message]}. developer_message is no longer exposed. [T]
  3. A body is validated against the published change serializer before it is applied, so these
    answer 400 naming the property where they used to answer 500: name as null/number/list/
    object/boolean, bio as null, email as a number or boolean, country as a list or object,
    phone_number as a number/list/object/boolean, and a social link sent without its address. [T]
  4. …and these answer 400 where the value used to be coerced and stored: bio, city,
    goals, mailing_address sent as a number, and account_privacy sent as a number, list,
    object or boolean. Before, {"bio": 5} stored "5". [T]
  5. The sentence for a wrongly typed value now says what the property takes (Not a valid string.,
    A valid integer is required.) instead of This value is invalid.. [T]
  6. A change that could not be carried out answers 500 internal with one stable sentence and
    the specifics in the log, where it used to read as 400 validation. A database failure is no
    longer reported to the caller as invalid input. [T]
  7. A site with ALLOW_EMAIL_ADDRESS_CHANGE off answers 400 naming email with this endpoint's
    sentence instead of a 400 carrying the operator-facing one. [T]
  8. account_privacy is published as nullable, which it already behaved as. [S]
  9. The rate-limit refusal {"error": "Too many requests"} becomes the envelope, still 429. [T]
  10. A body that is not a JSON object is refused 400 non_field_errors. [T]
  11. A request naming a response preset or field selection this endpoint cannot answer is refused
    (400) before the address it names is read or written — so a change to another account
    answers 400 with ?fields=email and 403 without it. The refusal is identical at a name no
    account 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/

  1. The address moved out of the account member slot to a top level of its own. [T][S]
  2. Bare array → pagination envelope. [T][S]
  3. Rows are ordered by username where the superseded address answered in database order. [U]
  4. A search names at most 100 addresses, and the answer is paged. A body naming more than 100
    is refused 400 with errors: {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 no
    page_size the answer is the first ten accounts and a link to the rest, where the superseded
    POST 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]
  5. The rest of a search is read by sending the body to the next link again; fetching that link
    with GET is 405. A page the search does not have is 404. [T][S]
  6. A missing emails member: {developer_message, user_message} → errors: {emails: ["This field is required."]}. emails that is not a list is refused 400 rather than passed to the query.
    [T]
  7. A caller who is not a platform administrator is told there is nothing at this address (404) in
    both versions; the body becomes the envelope. [T]
  8. The 403 a credential whose account is no longer active receives is now published. [T][S]
  9. GET on the collection is 405 with a body. [T]

POST …/deactivate/ → POST /api/user/v2/accounts/{username}/deactivation/

  1. 200 with the whole account body → 204 with no body, which costs six queries fewer. [T][S]
  2. An unknown username is answered 404 rather than raising. [T]
  3. me is read as a stored username here, exactly as at the superseded address: the operation
    never disables the caller's own account by that name. [T]
  4. The refusal for an unknown query parameter offers format only, where it offered
    format, page, page_size. [T]
  5. The published description no longer says a password reset brings the account back. [S]
  6. The media-type narrowing this branch briefly carried was removed: the operation reads
    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/

  1. 201 with an empty body → 201 {"name": …}. [T][S]
  2. name is capped at 255 characters and refused 400 above it; the superseded address accepted
    any length and would have failed at the column. [T]
  3. The refusal key new_name becomes name, and "The profile name given was identical to the
    current name." becomes "The profile already carries this name." [T]
  4. The operation reads application/json, application/x-www-form-urlencoded and
    multipart/form-data
    — what the superseded address read. All three are published. [T][S]
  5. Malformed JSON is 400 with type: …/malformed-body. [T]
  6. GET on the collection is 405 with a body. [T]

POST …/name_change/{username}/confirm/ → POST /api/user/v2/name_changes/{username}/confirmations/

  1. An unknown username is answered 404 rather than raising. [T]
  2. A caller exercising this privilege must hold an account that is still active, so a credential
    issued before its account was disabled is refused 403 here. [T]
  3. The 403 gains a body. [T]
  4. me names an account holding that name at this address, as the published parameter says. [T]

POST|DELETE …/accounts/{username}/image → …/accounts/{username}/profile_image/

  1. "No file provided": {developer_message, user_message} → errors: {file: […]}. The raw
    str(ImageValidationError) is no longer exposed. [T]
  2. A file that passes validation but that the imaging library cannot open is refused 400 with
    a 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]
  3. An image sent as the whole body without a Content-Disposition filename is refused 400. [T]
  4. DELETE for an account that carries no profile is a 404 envelope. [T]
  5. The permission reads the address and nothing else: a username in the query string or in a form
    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]
  6. The published request of the superseded POST …/image gains four media types (image/gif,
    image/jpeg, image/pjpeg, image/png) beside multipart/form-data; it published no request
    body before this change. [S]

GET …/verification_status/ and GET …/verifications/

  1. The attempts list changes from a bare array to a pagination envelope, default page size 10. [T][S]
  2. An account that has been through no check is answered 200 {"is_verified": false, "status": "none", "expiration_datetime": ""} at
    verification_status/, and an empty page at verifications/. 404 at these addresses now
    means 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 answered 200 for exactly the same account. Six
    tests pin it, and
    test_a_user_who_has_never_been_checked_is_told_apart_from_one_who_does_not_exist is the one
    that states the distinction. [T]
  3. New ordering parameter (updated_at / -updated_at / empty, default -updated_at), and ties
    are broken by receipt_id, so a page is stable where equal updated_at rows were left to the
    sort. [T][S]
  4. The username is read from the address only; supplying it in the query string is a refusal, not
    an alternative. [T]
  5. A caller exercising the administrator half of this privilege must hold an account that is
    still active
    : a credential issued before its account was disabled is refused 403 at another
    user's status and attempts, and is also refused the ?email= / ?lms_user_id= selectors on the
    account list. Its own records, and accounts it names by username, are unaffected. [T]
  6. expiration_datetime is published as a string constrained to MM/DD/YYYY or empty, described
    as the calendar day; an attempt's status documents all seven values the tables hold; type
    says a check carried out by an installed verification service is reported as SSO. Schema text
    only. [S]

me beneath an account

  1. me means three things in this version, and all three are documented in the published
    parameters and pinned by tests
    : it resolves to the caller at /accounts/me/; it is read as a
    stored 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 holds
    that name — a learner sending it is refused 403, an administrator sending it is told no
    account holds that name, and the user actually named me is served their own records. This
    is what ships, and it is deliberate
    : making me resolve beneath an account would store a
    profile image under the literal name me (the mutation that tried it showed exactly that), and
    resolving 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 that
    learned me from the member address meets a refusal one segment down, which is why all three
    meanings are in the published username parameters. [T][S]

Naming

  1. An address beneath an account is plural where it names rows that accumulate
    (verifications, confirmations, name_changes, email_searches) and singular where it
    names the one thing every account has (profile_image, verification_status, deactivation).

4. ADRs applied

  • 0025 serializers — every request body is validated by the serializer the operation
    publishes; response components are held to the bodies the endpoints return.
  • 0026 permissions — permission_classes on every view; every legacy check survives and two
    are added; deny paths tested (§10).
  • 0027 schema / docs — eleven operations and seventeen superseded paths published, with the
    media types each address reads, the refusals each answers, and the parameters each reads.
  • 0028 viewsets — router-registered viewsets; the two non-resource operations are APIViews
    with the reason in their docstrings; record selection lives in selectors.py.
  • 0029 errors — RFC 9457 envelope on every refusal, with this version's own
    ParseError/MethodNotAllowed/NotAcceptable/UnsupportedMediaType subclasses registered under
    their natural slugs.
  • 0030 GET idempotence — every read captured under 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.
  • 0031 merged endpoints — GET /api/user/v1/me is merged into the account member address,
    where ?fields=username reproduces the earlier body.
  • 0032 pagination — all three lists: the account list, the verification list and the email
    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.
  • 0033 filtering — ordering and every selector validated by a serializer and published
    with its allow-list, default and empty-value meaning; an unknown value is refused, not ignored.
  • 0034 authentication — the platform default on six of the eleven operations; the two views
    serving the other five make one declared deviation, substituting
    SessionAuthenticationAllowInactiveUser for the default session class, with the behavioural
    reason as a comment on the line above it.
  • 0035 MFE config — n/a: none of these eleven operations serves configuration, settings or
    feature flags to a frontend; they all read or change one user's own data.
  • 0036 nested JSON — the extra profile fields a site collects and the social links are read
    and changed through the account in the shape the site stored each value in; field_value is
    published as a JSON field.
  • 0037 versioning — a new v2 package and a new profile_images/v2 package; no
    pre-existing version directory is touched; the versions gate reports added in old dirs: 0.
  • 0038 URL structure — snake_case, plural collections, trailing slash, no version token in a
    path segment, reverse() literals and same-view resolution pinned for every legacy/conforming
    pair. The urls gate is red (2 FAIL, 1 WARN) and that is declared, not hidden: the three
    findings 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.
  • OEP-66 queryset scoping — the service-backed exemption, with the three layers named in
    §10; the two ORM-backed sibling lists in the same module declare FullScopePolicy.
  • OEP-69 conventions — pagination envelope, RFC 9457 refusals, strict parameter handling,
    snake_case bodies and addresses, one published document per service.

5. Compliance matrix

Decision Status How it is met, or the concrete reason it is excluded Evidence (test / gate)
0025 serializers met AccountUpdateSerializer validates the merge-patch body before the account service sees it; AccountQuerySerializer validates 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 suite AccountChangeBodyShapeTests (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: removing pending_name_change from the component fails 6 schema tests
0026 permissions met coarse check on each view plus a specific check per action; no inline if request.user.is_staff left; 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 read the deny matrix in §10; mutations: IsStaffOrAbsent.has_permission → True → 2 failures; IsAddressedUser relaxed → 5 failures; IsActiveUser made a no-op → 6 failures
0027 schema / docs met each operation publishes the media types it reads, the refusals it answers and the parameters it reads; the superseded paths publish bodies borrowed from the v2 serializers; the shared refusal descriptions come from one definition test_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.py 64 passed
0028 viewsets met AccountViewSet, EmailSearchViewSet, NameChangeViewSet, IDVerificationViewSet on the router; AccountDeactivationView and IDVerificationStatusView are APIViews because a deactivation is a non-resource operation and a status is one computed summary, both stated in their docstrings; selection is in selectors.py test_the_hand_routed_addresses_resolve_to_the_views_that_serve_them; test_addresses_resolve_to_the_version_two_viewsets; test_selectors.py (14)
0029 errors met StandardizedErrorMixin on every view; this version registers only its own ParseError/MethodNotAllowed/NotAcceptable/UnsupportedMediaType subclasses, so the process-global catalog is not re-pointed; a client fault is a 400 naming the property, work that failed is a 500 internal with one stable sentence and the specifics in the log AccountChangeFailureTests (5); assert_error_envelope used on every refusal path; v2/errors.py registrations pinned by test_schema.py
0030 GET idempotence met every read of an account, a search, a status and an attempts page wrapped in captureOnCommitCallbacks(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 excluded AccountReadsDoNotWriteTests + …WithARecoveryAddressTests (21 each); VerificationReadsDoNotWriteTests; TokenProvisionedAccountTests (3)
0031 merged endpoints met /api/user/v1/me is answered by the account member address, where ?fields=username reproduces the earlier body, so one address serves what two did test_the_callers_own_address_answers_with_the_whole_account; test_email_search_parity
0032 pagination met on all three lists the account list, the verification list and the email search answer 10 rows by default and never more than 100; the search is bounded on both sides, because a request naming more addresses than the largest page could carry is refused rather than answered, and the bound is published as maxItems on the request AccountPageSizeTests (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 = 3 and max_page_size = 100000 each go red; lifting the emails bound turns 3 tests red; putting the answer back in one page turns 6 red
0033 filtering met ordering published 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 refused test_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_refused
0034 authentication met, one declared deviation six of the eleven operations declare no authentication_classes; AccountViewSet (3 operations) and ProfileImageView (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 above test_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 red
0035 MFE config excluded this area publishes no configuration endpoint —
0036 nested JSON met the site's extra profile fields are read and changed through the account keeping each stored shape; field_value is published as a JSON field ExtendedProfileConfiguredTests (4) and ExtendedProfileUnconfiguredTests; test_a_field_the_site_does_not_collect_is_left_out_of_the_account
0037 versioning met all new code is in user_api/v2/ and profile_images/v2/; profile_images/{views,urls,images,exceptions}.py are byte-identical to master and 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 publication versions gate PASS (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 path
0038 URL structure met on every address; gate red on one inherited fallback route eleven conforming addresses, each with its reverse() literal, its route name, its same-view resolution and its converter refusals pinned; the search moved out of the member slot before publication test_urls.py (34), test_url_mount.py (4), test_an_account_named_after_another_collection_is_served_at_its_own_address; urls gate FAIL (2 FAIL, 1 WARN) — all three on the unnamed fallback, §11
OEP-66 scoping met (service-backed exemption) record selection is a named factory (usernames_among); there is no view-level get_queryset over the resource's own table, so there is no scoping_policy to declare, and the docstring says so; caller scoping is the permission pair, and field visibility stays in the account service test_selectors.py (14); the three-layer table in §10; mutation: the selector gate removed goes red
OEP-69 conventions met snake_case addresses and bodies, trailing slashes, the seven-member page envelope, RFC 9457 refusals, strict query parameters, one document per service test_urls.py, test_schema.py, test_views.py

6. Backward compatibility

/api/user/v1/ is untouched. No v1 view, serializer, URL or test is in this diff. The versions
gate reports added in old dirs: 0, and the fourteen protected paths were re-verified per path
(git diff + git ls-files --others each): 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. All
35 INFO are "operation added". Every enrollment operationId survives the widened
SCHEMA_PATH_PREFIX, pinned by test_enrollment_operation_ids_survive_the_widened_prefix.

Record bodies are identical. INTENTIONAL_DIFFERENCES in test_account_parity.py is, verbatim:

INTENTIONAL_DIFFERENCES = {
    "account_list": [],
    "account_detail": [],
    "shared_account": [],
    "another_account": [],
    "changed_account": [],
    "email_search": [],
}

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-tests gate is red and blind here, and it is not the compatibility
evidence.
It collects openedx/core/djangoapps/user_api/tests/ only — none of the four surfaces
this phase supersedes
— and its 7 failures and 16 errors are pymongo connection timeouts in this
container. The real measurement is per surface, run base-vs-branch in the same image:

superseded surface base (origin/master) this branch
user_api/accounts/ 18 failed / 285 passed / 18 errors, 36 unique failing nodes identical: 18 failed / 285 passed / 18 errors, the same 36 nodes, diff empty
profile_images/ 73 passed 73 passed
verification_api/ 16 passed 16 passed
user_api/preferences/ the known ModuleStoreTestCase mongo teardown pattern the same pattern

This proves that no legacy test which passes on master fails on this branch. It does not
prove
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.py imports
validate_uploaded_image, create_profile_images, remove_profile_images and IMAGE_TYPES from
the 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.

File Sanctioned category What, and the evidence
lms/lib/spectacular.py strictly opt-in addition SUPERSEDED_USER_PATHS extended from Phase A's seven to seventeen; superseded_bodies() added so each superseded operation publishes the body it reads and answers; PUBLISHED_USER_VIEW_MODULES extended; one RENAMED_OPERATION_IDS entry 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_derives
lms/lib/tests/test_spectacular.py test for the above 17 passed

Phase 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 diff
against master only 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 deprecated text in this diff is the post-processing
hook, its tests, and a test asserting no Deprecation header 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 superseded
bodies 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 would
break those readers on an address they already depend on.

Within v2 the envelope comes from StandardizedErrorMixin in edx-drf-extensions; this version
registers error types only for its own ParseError, MethodNotAllowed, NotAcceptable and
UnsupportedMediaType subclasses, so the process-global catalog other apps share is not
re-pointed, and CatalogedRequestErrorsMixin substitutes 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 marked
deprecated: true in the published schema. The superseded views are unchanged: no response
header, 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_PATHS exactly; 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.

Operation Who may call it Classes Layer
GET /accounts/, GET,PATCH /accounts/{username}/ an authenticated caller, with the account service deciding per caller which properties the answer carries; selecting accounts by email address or numeric identifier is an administrator privilege and requires an active account IsAuthenticated, CanGetAccountInfo, CanSelectAccountsByContact coarse on the viewset, specific per selector
POST /email_searches/ an active platform administrator; every other authenticated caller is told there is nothing at this address IsAuthenticated, IsActiveUser, CanGetAccountInfo, IsStaffOrAbsent four classes in order
POST /accounts/{username}/deactivation/ the callers the superseded address allowed, and their account must be active IsAuthenticated, IsActiveUser, CanDeactivateUser coarse + specific
POST /name_changes/ any authenticated caller, for their own profile IsAuthenticated coarse
POST /name_changes/{username}/confirmations/ an active platform administrator IsAuthenticated, IsActiveUser, IsStaff (on the action) per-action
GET …/verification_status/, GET …/verifications/ the account named by the address, or an administrator whose own account is still active IsAuthenticated, (IsStaff & IsActiveUser) | IsAddressedUser coarse + specific
POST,DELETE …/profile_image/ the account named by the address, as at the superseded address — administrators are refused IsAuthenticated, IsAddressedUser coarse + specific

IsAddressedUser reads the address and nothing else, and compares the name as it is stored: the
address 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)

layer what it is here where
record selection usernames_among(names, ordering) — the accounts holding the names given, ordered v2/selectors.py
caller scoping no scope filter: the page carries only accounts the caller named, or the one behind an address or identifier that only an active administrator may select by, or the caller's own account when no selector is given v2/permissions.py, AccountViewSet._selected_names
field visibility the account service answers whole accounts for the caller's own account and for an administrator, and the owner's shared properties otherwise; ?fields= is measured against what a response of this kind can carry for this caller accounts/api.py (service), AccountViewSet.shapeable_fields

Deny-path tests

caller address expected
anonymous each of the eleven operations 401 authn with a body
a learner another account's change 403; the account is unchanged
an administrator another account's change 403
a learner ?email= / ?lms_user_id= on the account list 403; also for their own address
a credential whose account was disabled after it was issued another user's status, attempts, and the two contact selectors 403; its own records and accounts it names by username are unaffected
a learner another user's status / attempts 403, and still 403 whatever the query string carries
a caller named for the address in case alone status, attempts, image set, image clear 403
a learner another learner's image, set and clear 403; nothing stored, the flag unchanged
an administrator another account's image, set and clear 403
a learner the email search 404 — there is nothing at this address
a credential whose account was disabled the email search 403
an unconfirmed session the email search 401
the caller who asked for a name change confirming their own request 403
another learner confirming someone's request 403
a credential whose account was disabled a confirmation 403
a name no account holds every address beneath an account 404 not-found, and nothing is written
a learner / an administrator / the user named me /accounts/me/{verification_status,verifications,profile_image}/ 403 / 404 / 200 with their own records

Named 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 in
IsAddressedUser → 5 failures; making IsActiveUser a no-op → 6 failures.

11. Gate report

run_gates.sh, final run on the rebased tree (origin/master @ 7f01c42157):

  versions             PASS
  hygiene              PASS
  schema               PASS
  urls                 FAIL
  old-tests            FAIL
  tests                PASS
GATES FAILED: 2
Gate Result Reading
versions PASS files in new version dirs: 29 other files: 7 added in old dirs: 0 allowed: 0. The gate's --allow arguments are mechanically inert here and it does not echo its resolved protect list, so the per-path evidence is a git diff and a git ls-files --others on each of the fourteen protected paths: all zero
hygiene PASS (0 FAIL, 1 WARN) 36 files scanned. The one WARN is H3 on user_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 else
schema PASS (0 BREAKING, 54 WARN, 35 INFO) All 35 INFO are "operation added". The 54 WARN are: 4 "new write operation documents no requestBody" — two superseded and two conforming POSTs that genuinely read no body (a deactivation and a confirmation, both answering 204); 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 v1 AccountViewSet
urls FAIL (2 FAIL, 1 WARN) — deliberate 3,051 head routes against 3,036 base, 15 new API routes checked. All three findings are on one route, api/user/v2/{_}.: FAIL R6 missing trailing slash, FAIL R8 identifier directly under the version, WARN R11 no 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). The WARN R2 on accounts/email_searches/ that the previous run carried is gone: that address moved to the top-level POST /api/user/v2/email_searches/ before publication. Blind on: R6/R8/R11 for a fallback route — reported, not suppressed
old-tests FAIL — red and blind 7 failed / 100 passed / 16 errors, every one a pymongo connection timeout in this container. The gate collects user_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 is
tests PASS 903 passed: the 900 of the run above, plus the three the search bound added afterwards (re-run in the same image, 58s)

One gate must be re-run before merge: schema. The table above was measured before the email
search was bounded, which adds maxItems: 100 to the published EmailSearch request and rewords
that 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: hygiene re-run (PASS, 0 FAIL, the same single
H3 WARN), the suite re-run in full (903 passed), and versions, urls and old-tests cannot
have 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=pylintrc over every touched module (no
messages, re-run after the change above), and a drf-spectacular generation plus read-back of the
generated operations.
isort --check-only is not a signal in this repo — there is no isort configuration, and the
default profile flags untouched files on master too.

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:

Module Tests
user_api/v2/tests/test_account_views.py 230
user_api/v2/tests/test_verification_views.py 85
user_api/v2/tests/test_schema.py 64
profile_images/v2/tests/test_views.py 51
user_api/v2/tests/test_name_change_views.py 43
user_api/v2/tests/test_urls.py 34
user_api/v2/tests/test_account_parity.py 27
user_api/v2/tests/test_account_query_counts.py 22
lms/lib/tests/test_spectacular.py 17
user_api/v2/tests/test_selectors.py 14
user_api/v2/tests/test_url_mount.py 4
Phase A's modules, the frozen profile_images/ suite and enrollments v2, re-run unchanged 312

Seven of those modules are new in this phase (472 tests); test_schema.py, test_urls.py and
test_spectacular.py are extended.

Query cost is asserted exactly, not bounded: test_account_query_counts.py pins that a page costs a
constant 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

  • H8 greps get/list/retrieve bodies, so it cannot see a write made through the
    verification or account service. The zero-write evidence is the runtime SQL captures
    (CaptureQueriesContext inside captureOnCommitCallbacks(execute=True)), which run on each
    data-bearing branch, on both sides of the secondary-email switch, and under both ways of
    identifying the caller.
  • schema_diff.py compare reads a path-key rename as a removal, and the attempts collection
    changed 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
    (ordering with its enum and default, page, page_size, username with its pattern),
    responses and component name.
  • The old-tests gate is blind (above); the per-surface runs in §6 are the evidence.
  • Mutation testing, not green suites. Each repair pass planted the behaviour back and confirmed
    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 = 3 and max_page_size = 100000
    on the paginator, a retyped account property, the selector gate removed, an authentication
    declaration planted on a viewset, the me name admitted by the address permission (which showed
    an image would otherwise be stored under the literal name me), the account-service failure
    classified back to 400, and — for the search bound — the emails bound lifted (3 tests red: the
    refusal, the version-to-version contrast and the published maxItems) and the answer put back
    into 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.

  1. flatten_detail puts a Python repr into detail. A nested validation refusal arrives as
    {"social_links": ["{'social_link': [ErrorDetail(string='This field is required.', code='required')]}"]}
    — the errors member beside it is correct, but detail carries an internal representation in a
    field 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.
  2. No OpenApiAuthenticationExtension for the platform's authenticators. 44 of this run's new
    generation 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 security block needs the library to register
    extensions for its JWT and session classes.
  3. DRF 3.17.2 parser RecursionError. rest_framework/parsers.py:63-68 catches ValueError
    only, 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_settings decides whole-account-versus-shared from the staff flag alone, so a
    credential 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_readable and _addressed_names compare usernames exactly. On a deployment
    whose 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.
  • An attempt recorded by an installed verification service is classified SSO. Documented, not
    changed — parity with the superseded body.
  • city is changeable through the account but is carried by no account response, because it is not
    in the service's field set.
  • The image upload stores the resized files before it records them on the account, so a 404 for an
    account carrying no profile happens after the sizes are written. The superseded view has the same
    order.
  • The superseded PATCH /api/user/v1/accounts/{username} does not list 415 among its published
    responses, 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 are
not asked to move in this phase.

SDK

The eleven operations are tagged openedx-platform-sdk, so the generated client libraries pick them
up; 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 pinned operation_id on the preferences member address whose pin
is better dropped so the document derives its own form, as the seven paired-key addresses the LMS
already publishes do.

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 branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant