Skip to content

Add the staff contract provisioning API (C3 3/3) - #3965

Merged
blarghmatey merged 4 commits into
b2b-c3-contract-servicesfrom
b2b-c3-contract-routes
Sep 25, 2026
Merged

blarghmatey merged 4 commits into
b2b-c3-contract-servicesfrom
b2b-c3-contract-routes

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

What are the relevant tickets?

Capability C3 of the B2B onboarding RFC, https://github.com/mitodl/hq/discussions/12784. Design and decisions: https://github.com/mitodl/hq/discussions/12784#discussioncomment-18437846.

Stack (3 of 3). Based on #3964. The consumer is the contract section of the staff UI.

Description (What does it do?)

Routes nested under /api/v0/b2b/provisioning/organizations/{org_key}/contracts/, so contracts can be set up without running b2b_contract, b2b_courseware, b2b_codes and check_contract_variant by hand:

Route Does
GET /, POST / list (paginated); create, with a default variant set
GET /{id}/, PATCH /{id}/ retrieve; update
POST /{id}/courseware/ add a program, course or run
POST /{id}/courseware/remove/ remove one
GET /{id}/setup-status/ clone status per run, expected vs existing codes, and in_progress / complete / failed
POST /{id}/retry-setup/ re-queue failed clones and the code check
GET /{id}/codes/ codes with their latest assignment (paginated)
POST /{id}/codes/expire/ take unused codes out of the contract
POST /{id}/codes/assign/ assign and email, as the manager dashboard's bulk assign does

Per the design comment:

  • A write returns once MITx Online's rows exist. edX clones and enrollment codes follow in Celery, and setup-status reports them from CourseRunClone (1/3).
  • Code generation is queued, never inline, because seat-capped contracts create max_learners codes per run. It's queued after PATCH and after courseware is added, and only for contracts that use codes. For the rest the check strips codes, which should stay a deliberate b2b_codes validate.
  • Nothing here sets OrganizationOnboarding to contract_ready. For an SSO org the contract is often ready before the IdP is validated, and the single ordered state would then say SSO is done.
  • The management commands and Wagtail editing keep working alongside the API.

The whole viewset is IsAdminUser, reads included, unlike the organization routes' IsAdminOrReadOnly. The codes routes return redeemable codes.

Also:

  • The manager dashboard's bulk-assign body moves into bulk_assign_enrollment_codes, so both surfaces assign codes the same way. The manager route's behaviour doesn't change.
  • b2b_codes expire calls expire_unused_enrollment_codes. Its dry run no longer assumes each code applies to exactly one product.
  • ContractSetupStatusEnum and CourseRunCloneStatusEnum are pinned in ENUM_NAME_OVERRIDES. Unpinned they published as StatusEnum and CloneStatusEnum.
  • v1.yaml and v2.yaml change along with v0.yaml because every version file carries every route (Fix OpenAPI spec partitioning #3824).

How can this be tested?

Run in the web container: pytest b2b openedx, 814 passed and 7 skipped. generate_openapi_spec --fail-on-warn exits 0 and the committed specs are its output. pre-commit passes.

b2b/views/v0/provisioning_contracts_test.py covers: staff-only access, scoping to the organization, create, the code check after PATCH, add then setup-status through to complete, a failed clone and retry, remove, and the three codes routes.

Not run by hand. Logged in as a staff user on a local stack, in the browsable API:

  1. POST /api/v0/b2b/provisioning/organizations/<org_key>/contracts/ with {"name": "Test", "membership_type": "code", "max_learners": 2}
  2. POST .../contracts/<id>/courseware/ with {"courseware_id": "<course readable_id with a source run>"}
  3. GET .../contracts/<id>/setup-status/ shows the run pending and the contract in_progress until the clone and code check finish

Additional Context

  • Restrict B2B page access to admins #3957 moves the read-only organizations/ and contracts/ viewsets to IsAdminUser. It edits b2b/views/v0/__init__.py, which this stack doesn't touch.
  • The regenerated specs will conflict with any other open PR that regenerates them.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NUA1EjC95LZV4J5swhZgxy

@github-actions

Copy link
Copy Markdown

OpenAPI Changes

Show/hide changes
## Changes for v0.yaml:
11 changes: 0 error, 0 warning, 11 info
info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API PATCH /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/codes/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/codes/assign/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/codes/expire/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/courseware/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/courseware/remove/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/retry-setup/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/setup-status/
		endpoint added



## Changes for v1.yaml:
11 changes: 0 error, 0 warning, 11 info
info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API PATCH /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/codes/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/codes/assign/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/codes/expire/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/courseware/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/courseware/remove/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/retry-setup/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/setup-status/
		endpoint added



## Changes for v2.yaml:
11 changes: 0 error, 0 warning, 11 info
info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API PATCH /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/codes/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/codes/assign/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/codes/expire/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/courseware/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/courseware/remove/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/retry-setup/
		endpoint added

info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API GET /api/v0/b2b/provisioning/organizations/{parent_lookup_organization__org_key}/contracts/{id}/setup-status/
		endpoint added



Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

Comment thread b2b/views/v0/provisioning.py
@blarghmatey
blarghmatey added this pull request to stack #3966 September 15, 2026 14:44
@blarghmatey
blarghmatey requested a balanced review from Copilot September 15, 2026 14:44
Comment thread b2b/views/v0/provisioning.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical correctness and concurrency issues remain in expiration, forced moves, contract creation, and code assignment.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds a staff-facing B2B contract provisioning API for contract CRUD, courseware setup, status tracking, retries, and enrollment-code management.

Changes:

  • Adds nested provisioning endpoints, serializers, routes, and tests.
  • Shares enrollment-code assignment and expiration logic across API, dashboard, and commands.
  • Updates status constants, enum naming, and OpenAPI specifications.
File summaries
File Reviewed changes
openapi/specs/v2.yaml Regenerated provisioning API specification.
openapi/specs/v1.yaml Regenerated provisioning API specification.
openapi/specs/v0.yaml Regenerated provisioning API specification.
openapi/settings_spectacular.py Pins provisioning status enum names.
b2b/views/v0/urls.py Registers nested contract provisioning routes.
b2b/views/v0/provisioning.py Adds provisioning endpoints. Critical (1 vote): missing membership_type causes TypeError; moderate (1 vote): assignment prefetch is not contract-scoped; nit (1 vote): code-contract queueing lacks endpoint coverage.
b2b/views/v0/provisioning_contracts_test.py Covers staff access, scoping, lifecycle, setup/retry, removal, and code routes.
b2b/views/v0/manager.py Centralizes bulk assignment. Critical (2 votes): concurrent requests can assign one code twice; moderate (1 vote): duplicate products can duplicate candidate codes.
b2b/serializers/v0/provisioning.py Defines provisioning serializers. Critical (1 vote): forced course-run moves leave prior M2M associations attached.
b2b/management/commands/b2b_codes.py Reuses shared enrollment-code expiration logic.
b2b/contracts.py Implements shared setup and expiration helpers. Critical (1 vote): shared products can cause codes and discounts for another contract to be deleted; moderate (1 vote): unused discounts are not distinct.
b2b/contracts_test.py Tests shared contract helpers.
b2b/constants.py Adds setup status constants.
Review details

Suppressed comments (4)

b2b/contracts.py:414

  • get_unused_discounts() is also not distinct, so a code attached to multiple products is processed once per product. Dry runs will return duplicate code rows, while real runs repeat detach/delete work for the same discount. Iterate over a distinct queryset here.
    for discount in list(contract.get_unused_discounts()):

b2b/views/v0/manager.py:202

  • get_discounts() joins through the contract's products and is not distinct. If a discount applies to more than one product, it appears multiple times here, so the same free code can be assigned to multiple people in one request. Add distinct() before the slice.
    available_discounts = list(
        contract.get_discounts()
        .filter(contract_redemptions__isnull=True)
        .order_by("id")[: len(email_assignees)]
    )

b2b/views/v0/provisioning.py:546

  • The new code-contract branch is not covered by the endpoint tests: test_add_courseware_and_follow_setup uses a managed, free contract, so queue_enrollment_code_check_if_required is never asserted for this action. Add a code-contract case that verifies adding a run queues the enrollment-code check; this is a key asynchronous behavior promised by the API and can otherwise regress independently of the PATCH test.
        if added.runs_added:
            queue_enrollment_code_check_if_required(contract)

b2b/views/v0/provisioning.py:639

  • This prefetch is not scoped to the contract being viewed. A course run can be attached to multiple contracts, so the same discount can have redemption rows whose contract values differ; the endpoint can then show the latest assignment from another contract. Filter the prefetch queryset by this contract before selecting the latest row.
                Prefetch(
                    "contract_redemptions",
                    queryset=DiscountContractAttachmentRedemption.objects.select_related(
                        "user"
                    ).order_by("-created_on")[:1],
  • Files reviewed: 13/13 changed files
  • Comments generated: 4
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread b2b/contracts.py
Comment thread b2b/serializers/v0/provisioning.py Outdated
Comment thread b2b/views/v0/manager.py
Comment thread b2b/views/v0/provisioning.py
Comment thread b2b/views/v0/provisioning.py

@jkachel jkachel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functionality works as described. 👍

I do have one concern. It's fine as-is for MVP, I think, but something that will need to be added somewhat soon: this will only add in the default variants for the contract. There's no provisioning for variant types into the contract, and thus the courseware added will only ever be the default variants. This is not a deal-breaker - we can add the variants in later via Django Admin and re-provision the courseware (which should not re-run the existing variants, unless that is requested specifically). But a lot of contracts will have some amount of variants beyond the default so there should be an API to handle the supported variant sets. (Courseware provisioning should respect the configured variant sets for the contract and course on its own.) Approving this since I don't think it's strictly necessary for this stage of things, but should definitely be part of the next phase of development.

blarghmatey and others added 4 commits September 25, 2026 12:38
Contract setup still meant running b2b_contract, b2b_courseware,
b2b_codes and check_contract_variant by hand. These routes, nested under
/api/v0/b2b/provisioning/organizations/{org_key}/contracts/, give the
staff UI the same operations: create, retrieve, PATCH, courseware add and
remove, setup status, retry, and enrollment codes list, expire and assign.

A write returns once MITx Online's rows exist. edX clones and enrollment
codes follow in Celery, and setup-status reports them from the
CourseRunClone records and the expected-versus-existing code count. Nothing
here sets OrganizationOnboarding to contract_ready: for an SSO org the
contract is often ready before the IdP is validated, and the single
ordered state would then claim SSO is done.

The whole viewset is IsAdminUser, reads included, unlike the organization
routes' IsAdminOrReadOnly. The codes routes return redeemable codes.

The code check is queued after PATCH and after courseware is added, and
only for contracts that use codes. For the rest, the check strips codes,
which should stay a deliberate b2b_codes validate.

The manager dashboard's bulk-assign body moves into
bulk_assign_enrollment_codes so both surfaces assign codes the same way.
b2b_codes expire calls expire_unused_enrollment_codes, whose dry run no
longer assumes a code applies to exactly one product.

The setup status enums are pinned in ENUM_NAME_OVERRIDES. Unpinned they
published as StatusEnum and CloneStatusEnum. The specs for v1 and v2
change along with v0 because every version file carries every route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUA1EjC95LZV4J5swhZgxy
CodeQL flagged the detail field built from str(exc) as information exposure
through an exception. The 400 bodies for a course with no source run and
for an invalid course key are now built from the requested courseware ID,
and the exception is logged instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUA1EjC95LZV4J5swhZgxy
CreateContractSerializer made membership_type optional because the model
has a default, while create_contract takes it as a required keyword. A POST
with only a name passed validation and then raised TypeError, a 500. The
field is now required, as it is for b2b_contract create, and the specs
regenerate with it in CreateContractRequest's required list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUA1EjC95LZV4J5swhZgxy
…r PATCH

Follows the removal of --force from b2b_courseware and the shared service:
the API's ContractCoursewareSerializer no longer takes force, and the
courseware endpoint no longer passes it. A run already in another contract
is reported as skipped. The specs regenerate without the field.

Sentry flagged that partial_update returned the contract still holding the
queryset's contract_programs prefetch, so the response could show the
programs as they were before the save. Cleared the same way DRF's
UpdateModelMixin does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUA1EjC95LZV4J5swhZgxy
@blarghmatey
blarghmatey force-pushed the b2b-c3-contract-routes branch from e39d010 to b80e8ff Compare September 25, 2026 16:38
@blarghmatey

Copy link
Copy Markdown
Member Author

@jkachel agreed, this goes in the next phase. Re-adding a course through courseware/ already skips any variant it has a run for (create_contract_run with no_reruns=True), so adding a set in Admin and posting the course again only creates the new variant's runs. What's missing is an API for the contract's variant sets and a way to re-provision the whole contract at once. Both are tracked, plus variant coverage in setup-status.

@blarghmatey
blarghmatey merged commit cd4a261 into main Sep 25, 2026
15 checks passed
@blarghmatey
blarghmatey deleted the b2b-c3-contract-routes branch September 25, 2026 16:59
This was referenced Sep 28, 2026
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.

4 participants