Skip to content

feat: Creation APIs for Programs, Opportunities, and related resources#1070

Draft
jjackson wants to merge 12 commits intodimagi:mainfrom
jjackson:jj/creation-apis
Draft

feat: Creation APIs for Programs, Opportunities, and related resources#1070
jjackson wants to merge 12 commits intodimagi:mainfrom
jjackson:jj/creation-apis

Conversation

@jjackson
Copy link
Copy Markdown
Member

@jjackson jjackson commented Mar 23, 2026

Product Description

Adds REST API endpoints to enable full end-to-end creation of Programs, ManagedOpportunities, PaymentUnits, DeliverUnits, and user invitations — filling the gap where these operations were previously only available through the web UI.

A new create OAuth scope gates all write endpoints. Read endpoints require only authentication (no scope), matching the existing OpportunityViewSet pattern.

Technical Summary

Jira: CCC-XXX (feature request for Connect team)
Draft PR: #1070

New Endpoints

Method URL Auth Purpose
GET /api/lookups/delivery_types/ IsAuthenticated List delivery types
GET /api/lookups/currencies/ IsAuthenticated List currencies
GET /api/lookups/countries/ IsAuthenticated List countries
GET/POST /api/program/ Read: IsAuth / Write: + create scope + PM admin List/create programs
GET/POST /api/program/<id>/opportunity/ Read: IsAuth / Write: + create scope + PM admin List/create managed opportunities
GET/POST /api/program/<id>/applications/ Read: IsAuth / Write: + create scope + PM admin List/invite orgs to program
GET/POST/DELETE /api/opportunity/<id>/payment_units/ Read: IsAuth / Write: + create scope + PM admin CRUD payment units
GET/POST/DELETE /api/opportunity/<id>/deliver_units/ Read: IsAuth / Write: + create scope + PM admin CRUD deliver units
POST /api/opportunity/<id>/invite/ create scope + PM admin Invite users by phone number

Key Design Decisions

  • OAuth scope: New create scope for writes only (mirrors export scope pattern). Reads require only IsAuthenticated, matching existing API convention.
  • Permissions: IsOrgProgramManagerAdmin DRF permission class mirrors the existing org_program_manager_required decorator. For nested routes (under Program), initial() injects the PM org slug from the program for permission checking.
  • Queryset scoping: All read querysets are scoped to the user's org memberships (direct org membership OR PM org via program), preventing cross-org data leakage.
  • Program inheritance: ManagedOpportunity auto-inherits currency/country/delivery_type from parent Program.
  • Response format: ReadSerializerResponseMixin ensures POST responses return the full read serializer (with id, UUID, slug, timestamps) instead of just echoing input fields.
  • Validation: DeliverUnit validates that payment_unit belongs to the URL opportunity. InviteUsers checks has_ended before queueing.

New Files

commcare_connect/opportunity/api/permissions.py   - IsOrgProgramManagerAdmin permission class
commcare_connect/opportunity/api/lookups.py       - Lookup viewsets (DeliveryType, Currency, Country)
commcare_connect/program/api/__init__.py          - Package init
commcare_connect/program/api/serializers.py       - Program, ManagedOpp, ProgramApplication serializers
commcare_connect/program/api/views.py             - Program, ManagedOpp, ProgramApplication viewsets
commcare_connect/opportunity/tests/test_creation_api.py - 14 tests
commcare_connect/program/tests/test_creation_api.py     - 12 tests

Safety Story

  • All write endpoints require both OAuth create scope AND org program manager admin role
  • Read querysets scoped to user's org memberships (prevents cross-org enumeration)
  • DeliverUnit validates payment_unit FK belongs to same opportunity (prevents cross-opp injection)
  • InviteUsers checks opportunity.has_ended (returns 400 instead of silently queueing no-op)
  • Non-existent program_id returns 404 (not confusing 403)
  • 26 tests covering create, list, permission rejection, validation, and edge cases
  • Full existing test suite (696 tests) passes with no regressions
  • Stress tested locally against running dev server (full E2E flow)

QA Plan

  • Verify OAuth token with create scope can access write endpoints
  • Verify OAuth token without create scope gets 403 on writes but 200 on reads
  • Verify non-PM org admin gets 403 on write endpoints
  • Verify unauthenticated requests get 401
  • Test full creation flow: Program -> ProgramApplication -> ManagedOpportunity -> PaymentUnit -> DeliverUnit -> Invite
  • Verify POST responses include full read serializer (id, UUID, slug, timestamps)
  • Verify invite to ended opportunity returns 400
  • Verify cross-org queryset scoping (user from org A cannot read org B's resources)

🤖 Generated with Claude Code

jjackson and others added 10 commits March 23, 2026 07:24
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Drop TokenHasScope from read (GET/list) actions — only require
  'create' OAuth scope for write operations (POST/PATCH/DELETE),
  matching how existing endpoints use IsAuthenticated for reads
- Add DeliverUnitReadSerializer with proper read fields (id, slug,
  name, payment_unit, app, optional) so GET responses use a read
  serializer like every other viewset
- Scope ProgramViewSet queryset to user's org memberships, matching
  how OpportunityViewSet filters by user access
- Update tests: list endpoints use force_authenticate (no scope),
  add explicit test for no-scope-required reads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jjackson
Copy link
Copy Markdown
Member Author

Code review

Found 1 issue:

  1. Read endpoints for nested resources (PaymentUnit, DeliverUnit, ManagedOpportunity, ProgramApplication) do not scope querysets to the user's organization membership. Any authenticated user who knows a valid opportunity_id or program_id UUID can enumerate resources belonging to other organizations. ProgramViewSet correctly filters by organization__memberships__user=self.request.user, but the nested viewsets do not apply equivalent filtering. Write operations are properly gated by IsOrgProgramManagerAdmin.

return context
def get_queryset(self):

def get_queryset(self):
return ManagedOpportunity.objects.filter(program__program_id=self.kwargs["program_id"]).order_by(
"-date_created"
)

Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

jjackson and others added 2 commits March 23, 2026 09:25
- Scope read querysets to user's org membership: PaymentUnit, DeliverUnit
  filter by user membership in either the NM org or PM org (via program).
  ManagedOpportunity and ProgramApplication filter by PM org membership.
  Prevents authenticated users from enumerating resources across orgs.
- Validate DeliverUnit's payment_unit belongs to the URL opportunity,
  preventing cross-opportunity FK injection
- Add has_ended check to InviteUsersView — return 400 instead of
  silently queueing a no-op Celery task (matches web UI behavior)
- Replace silent `except Program.DoesNotExist: pass` with
  get_object_or_404 in initial() — returns proper 404 instead of
  confusing 403
- Extract ProgramNestedViewMixin to DRY up shared initial/context logic
- Remove unused IsOrgAdmin permission class

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DRF's default CreateModelMixin returns the write serializer in
create responses, which omits read-only fields like id, program_id,
slug, date_created etc. Add ReadSerializerResponseMixin that overrides
create() to serialize the response with the read serializer, so POST
responses include full object details.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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