Skip to content

Admin Portal v1 (Week 1): platform-admin gate, org management, users tab (Issue #1254) - #1432

Open
Veekshitha11 wants to merge 33 commits into
DalgoT4D:mainfrom
Veekshitha11:feature/admin-portal-m4-users
Open

Admin Portal v1 (Week 1): platform-admin gate, org management, users tab (Issue #1254)#1432
Veekshitha11 wants to merge 33 commits into
DalgoT4D:mainfrom
Veekshitha11:feature/admin-portal-m4-users

Conversation

@Veekshitha11

@Veekshitha11 Veekshitha11 commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Combined PR for the full Week 1 Admin Portal build (features/admin-portal/v1/plan.md), covering all four milestones: M1 (platform-admin gate), M2 (/admin shell + dashboard), M3 (organizations: list/create/edit), M4 (users tab: invite/role/remove/cancel-invite), plus a local-dev SES fallback and a full end-to-end lifecycle test. Org/user deactivation was scoped out of this PR -- the is_active enforcement added early on was removed rather than left half-wired, since no endpoint ever set it (see PR review discussion). Reintegrated onto current main -- originally built on an older base, then reconciled with two upstream changes that landed in the meantime: RBAC v2's role collapse (#1414, no behavior change to this work -- aliases preserved) and a cascade-delete fix (#1428) that independently fixed the same dashboard/chart-deletion bug this PR had flagged as a fast-follow; removal-impact messaging updated accordingly (orphaned, not deleted). Full regression suite green -- see commit messages for milestone-by-milestone detail.

Summary by CodeRabbit

  • New Features

    • Added a platform-admin portal for viewing platform health, statistics, organizations, and organization details.
    • Added organization creation and editing.
    • Added organization user management, including invitations, role changes, removal-impact previews, and membership deletion.
    • Added platform-admin status to current-user and login responses.
    • Added development email fallback logging when email credentials are unavailable.
  • Bug Fixes

    • Improved invitation scoping and acceptance across organizations.
    • Authentication failures now return consistent HTTP 401 responses.

Veekshitha11 and others added 8 commits July 15, 2026 03:16
…DalgoT4D#1254, M1)

Admin Portal v1, Milestone 1 (platform-admin gate + client can see it) —
backend half. Adds the cross-org authorization primitive the whole portal
depends on, and surfaces who is a platform admin to the client.

- auth.py: new @platform_admin_required decorator, mirroring the existing
  @has_permission pattern; reads UserAttributes.is_platform_admin. This is
  the only wall between an org admin and cross-org data.
- models/org_user.py: add is_platform_admin to OrgUserResponse.
- api/user_org_api.py: populate is_platform_admin in get_current_user_v2.
- api/admin_api.py: new admin_router with a stub GET /ping guarded by
  @platform_admin_required.
- routes.py: register admin_router at /api/v1/admin/.
- tests/api_tests/test_admin_api.py: guard 403/200 + currentuserv2 flag.

No migration required (Ninja schema + decorator only; no model changes).

Tests:
- test_admin_api.py: 5 passed (non-admin 403, flag-false 403, admin 200,
  currentuserv2 true/false).
- test_user_org_api.py: 54 passed (no regression in get_current_user_v2).

Acceptance (Milestone 1):
- a platform admin's /currentuserv2 returns is_platform_admin: true
- /api/v1/admin/ping returns 200 for admins, 403 for everyone else

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#1254, M2)

Admin Portal v1, Milestone 2 (the /admin shell) — backend half. Adds the
platform-wide counts the dashboard renders.

- api/admin_api.py: new GET /api/v1/admin/stats (AdminStatsSchema), guarded by
  @platform_admin_required. Returns total_orgs (Org count) and total_users
  (distinct users across orgs via OrgUser, so a user in two orgs counts once —
  consistent with total_orgs meaning real orgs).
- tests/api_tests/test_admin_api.py: /stats is 403 for a non-admin; 200 with
  correct counts for an admin (incl. the distinct-user case).

Stacked on feature/admin-portal-m1-platform-admin-gate (depends on the M1 guard).

Tests:
- test_admin_api.py: 7 passed (5 from M1 + 2 new /stats).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… M3)

Admin Portal v1, Milestone 3 (Organizations) — the risky half, isolated for
review: the schema + the shared-middleware change that can lock users out.

- models/org.py: new is_active BooleanField(default=True).
- migrations/0161_org_is_active.py: AddField with default=True, so every
  existing org backfills to active (Django applies the default to existing
  rows; no data migration needed).
- auth.py: CustomJwtAuthMiddleware.authenticate now raises HttpError(403)
  "your organization has been deactivated" when the resolved org's is_active
  is False — enforced at permission-load, before any endpoint runs. Done with
  an explicit 403 (not empty permissions) because @has_permission's bare-except
  turns an empty-permissions 403 into a 404; this returns a real 403. See
  features/admin-portal/v1/plan.md §4.2.

Tests (ddpui/tests/core/test_auth.py):
- test_authenticate_blocks_deactivated_org: deactivated org -> 403 at
  permission-load (the single most important test this milestone).
- test_authenticate_allows_reactivated_org: reactivation restores access,
  permissions load (symmetry).
- Full test_auth.py suite (6) still passes — the 4 pre-existing tests prove the
  normal login/permission-load path is unaffected for active orgs.

Stacked on feature/admin-portal-m2-admin-shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… M3)

Admin Portal v1, Milestone 3 — the cross-org org-management endpoints, all
gated by @platform_admin_required.

- api/admin_api.py:
  - GET  /api/v1/admin/orgs                list all orgs (+ user counts, status)
  - POST /api/v1/admin/orgs               create (reuses create_organization +
                                          create_org_plan; Airbyte rollback kept)
  - GET  /api/v1/admin/orgs/{id}          detail
  - PUT  /api/v1/admin/orgs/{id}          edit name/viz_url/base_plan — slug is
                                          LOCKED (absent from AdminUpdateOrgSchema)
  - POST /api/v1/admin/orgs/{id}/deactivate   reversible
  - POST /api/v1/admin/orgs/{id}/reactivate
  Schemas: AdminOrgSchema (incl. viz_url), AdminCreateOrgSchema (plan defaults),
  AdminUpdateOrgSchema (no slug).

Tests (ddpui/tests/api_tests/test_admin_api.py):
- create happy path (Org + OrgPlans; Airbyte mocked) and the rollback path —
  a failed setup_airbyte_workspace_v1 leaves ZERO trace (0 Org, 0 OrgPlans) + 400.
- list, detail-404, edit-locks-slug, edit-updates-base_plan, deactivate+reactivate.
- guard: non-admin -> 403.
- test_admin_api.py: 15 passed.

Stacked on feature/admin-portal-m2-admin-shell (needs Org.is_active from commit A).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve (DalgoT4D#1254, M4)

The shared-code half of M4. Two schema additions and a refactor of the
core user-management functions that the ENTIRE app uses, kept behind
behavior-preserving single-org wrappers so the regular app is unchanged.

Schema:
- OrgUser.is_active: per-(user, org) active flag, distinct from the global
  User.is_active. Migration B backfills it from the user's current
  User.is_active (globally-disabled users start disabled everywhere; safe
  default per plan.md §4.1).
- Invitation.invited_in_org: explicit target-org FK (nullable). An
  invitation's org was previously derived from invited_by.org, which breaks
  when a platform admin invites into an org they don't belong to. Migration C
  backfills it from invited_by.org for every existing row, so pending invites
  resolve to exactly the same org as before.

Core refactor (one code path, no drift):
- invite_user_to_org / delete_orguser_from_org / change_orguser_role_in_org
  take the target org explicitly and accept is_platform_admin to skip the
  inviter/role-level cap for a cross-org admin. The old invite_user_v1 /
  delete_orguser_v1 / post_modify_orguser_role become thin wrappers.
- accept_invitation_v1 resolves the joined org as
  invited_in_org or invited_by.org — identical to before for existing rows.

Auth:
- Block a per-org-deactivated OrgUser at permission-load (mirrors M3's
  org-level block). Field is NOT NULL default True + backfilled from active,
  so an active user is never blocked. See plan.md §4.2.

Tests: regression proving single-org invite+accept round-trip and legacy
(invited_in_org=None) accept are unchanged; per-org deactivation blocks at
auth, active user still authenticates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cancel (DalgoT4D#1254, M4)

The additive half of M4: eight cross-org, platform-admin-gated endpoints
under /api/v1/admin/orgs/{org_id}/... that reuse the org-parameterized core
functions with is_platform_admin=True. No shared-app code touched here.

- GET  .../users                    list members (per-org status) + pending invites
- POST .../users/invite             invite at ANY role (inviter cap skipped)
- PUT  .../users/{ouid}/role        change role (level cap skipped)
- POST .../users/{ouid}/deactivate  per-org deactivate (OrgUser.is_active=False)
- POST .../users/{ouid}/reactivate  per-org reactivate
- GET  .../users/{ouid}/removal-impact  exact dashboards/charts/reports counts
- DEL  .../users/{ouid}             remove (cascades content) — warn first
- DEL  .../invitations/{iid}        cancel, scoped by invited_in_org (wrong org -> 404)

The cancel endpoint deliberately does NOT reuse the loose global
DELETE /users/invitations/delete/{id} (no org scoping — research §8); it
requires invited_in_org == the target org.

Tests: invite-cap-skip (with the refused-as-regular-inviter contrast),
removal-impact count accuracy vs real rows, per-org deactivate isolation
(other org + global flag untouched), remove cascade + SET_NULL orphaning,
org-scoped cancel, and guard 403s on the new routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DalgoT4D#1254)

SES local-dev fallback:
- awsses.send_text_message logs the email instead of raising when
  settings.DEBUG is True AND SES creds are absent. Gating on DEBUG means
  it can never mask a real SES misconfiguration in staging/prod, where
  DEBUG is off — those still raise loudly. Lets the invite flow complete
  locally with no SES setup.

Full-flow test:
- One narrative test that runs the whole admin journey on real DB state:
  create org (zero members) -> invite (exercises the SES fallback, no
  email mock) -> accept -> change role -> per-org deactivate (+ proves a
  second org is unaffected) -> reactivate -> org-level deactivate/
  reactivate -> cancel invite (+ cross-org 404) -> removal-impact then
  cascade-remove. Permission-load blocking is proven through the real
  CustomJwtAuthMiddleware, mocking only Redis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion renumber, JTI blacklist (DalgoT4D#1254)

Reintegration reconciliation after rebasing M1–M4 onto upstream/main:

Cascade -> orphan (Access Control v2 / PR DalgoT4D#1428 switched Dashboard & Chart
created_by from CASCADE to SET_NULL; ReportSnapshot already was):
- RemovalImpactSchema: rename dashboards_deleted/charts_deleted ->
  dashboards_orphaned/charts_orphaned (reports_orphaned unchanged); reword
  the endpoint + schema docstrings from "cascade-delete" to "orphan (kept,
  created_by NULLed)". Count logic (filter on created_by) is unchanged.
- Tests: test_admin_remove_user_cascades_content -> _orphans_content, now
  asserting Dashboard/Chart rows SURVIVE with created_by=None; flow-test
  Step 10 asserts the same. ReportSnapshot assertion was already correct.

Migration renumber: our 0161/0162/0163 collided with upstream's own
0161–0163. Renumbered to 0169/0170/0171, dependencies rechained onto
upstream's leaf 0168. Graph is linear (showmigrations verified).

JTI blacklist: upstream added a token-blacklist redis.get as the first
lookup in the auth middleware. Updated the four M3/M4 auth tests to return
None for that first call (mirroring upstream's own test_authenticate_success
side_effect) so the deactivation tests exercise the org/per-org block, not
a false "token invalidated".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 24387ba7-eddd-4382-a5f5-28f65278a957

📥 Commits

Reviewing files that changed from the base of the PR and between eca9865 and 20720fe.

📒 Files selected for processing (4)
  • ddpui/api/admin_api.py
  • ddpui/core/admin/admin_service.py
  • ddpui/schemas/admin_schema.py
  • ddpui/tests/api_tests/test_admin_api.py
💤 Files with no reviewable changes (2)
  • ddpui/core/admin/admin_service.py
  • ddpui/api/admin_api.py

Walkthrough

Adds a platform-admin portal for cross-organization administration, scoped membership and invitation management, organization and membership deactivation, authentication enforcement, invitation targeting, and development-safe email behavior.

Changes

Admin portal lifecycle

Layer / File(s) Summary
State fields and access enforcement
ddpui/models/*, ddpui/migrations/*, ddpui/auth.py, ddpui/api/user_org_api.py
Adds organization and membership activation state, invitation target-org tracking, platform-admin authorization, deactivation checks, and platform-admin identity exposure.
Scoped invitation and membership core
ddpui/core/orguserfunctions.py, ddpui/api/user_org_api.py, ddpui/tests/api_tests/test_user_org_api.py
Moves invitation and deletion logic to explicit target-org helpers and preserves legacy invitation acceptance fallback.
Admin service contracts and operations
ddpui/schemas/admin_schema.py, ddpui/core/admin/*
Defines admin schemas and errors, then implements organization lifecycle, invitation, membership, and removal-impact operations.
Admin portal API and routing
ddpui/api/admin_api.py, ddpui/routes.py
Adds platform-admin identity, health, statistics, organization, Users-tab, invitation, and membership routes under /api/v1/admin/.
Lifecycle and integration validation
ddpui/tests/api_tests/*, ddpui/tests/core/*, ddpui/utils/awsses.py, ddpui/tests/utils/test_awsses.py
Covers admin lifecycle behavior, authentication enforcement, scoped operations, invitation compatibility, and development SES fallback behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: ⚪ Minimal · up to 20720

The PR adds the Week 1 admin portal capabilities and related lifecycle behavior; no actionable merge-blocking risk remains beyond normal checks and review.

Possibly related PRs

Suggested reviewers: fatchat

Sequence Diagram(s)

sequenceDiagram
  participant PlatformAdmin
  participant admin_router
  participant admin_service
  participant Database
  PlatformAdmin->>admin_router: request organization or user operation
  admin_router->>admin_service: authorize and execute scoped action
  admin_service->>Database: read or update organization state
  Database-->>admin_service: return persisted data
  admin_service-->>admin_router: return service result
  admin_router-->>PlatformAdmin: return admin schema response
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 95.29% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: Admin Portal v1, platform-admin authorization, organization management, and user administration.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feature/admin-portal-m4-users
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
ddpui/core/orguserfunctions.py (2)

200-219: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Email lookup uses exact match, not __iexact/normalized — inconsistent with sibling functions.

user__email=payload.email (line 200-202) and invited_email=payload.email (line 217-219) do exact-string matching, unlike invite_user_to_org (user__email__iexact=invited_email, line 255) and change_orguser_role_in_org (user__email__iexact=request_email, line 358) in this same file. Since stored User.email is always lowercased on creation, a caller passing a differently-cased email (e.g. from a user-supplied DeleteOrgUserPayload.email via the single-org delete_orguser_v1 wrapper) would incorrectly get "user does not belong to the org" and skip invitation cleanup for an existing member.

🐛 Proposed fix
+    normalized_email = payload.email.lower().strip()
     orguser_to_delete = OrgUser.objects.filter(
-        org=target_org, user__email=payload.email
+        org=target_org, user__email__iexact=normalized_email
     ).first()
     ...
     # remove the pending invitations for this email in the target org
     Invitation.objects.filter(
-        invited_in_org=target_org, invited_email=payload.email
+        invited_in_org=target_org, invited_email__iexact=normalized_email
     ).delete()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ddpui/core/orguserfunctions.py` around lines 200 - 219, Update the member
lookup in the surrounding delete-orguser flow to use case-insensitive email
matching, consistent with invite_user_to_org and change_orguser_role_in_org.
Apply the same case-insensitive matching to the Invitation cleanup query so
differently-cased payload.email values identify the existing user and remove
pending invitations.

283-297: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Resend branch returns InvitationSchema under a NewInvitationSchema endpoint. post_organization_user_invite_v1 declares response=NewInvitationSchema, but this branch returns from_invitation(invitation), and from_invitation() builds InvitationSchema fields (invited_by, invited_on, invite_code, invited_new_role_slug). This can break response serialization; return the same NewInvitationSchema shape as the other invite paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ddpui/core/orguserfunctions.py` around lines 283 - 297, Update the
existing-invitation branch in post_organization_user_invite_v1 to return a
NewInvitationSchema-shaped result instead of from_invitation(invitation), which
produces InvitationSchema fields. Match the response construction used by the
other invite paths while preserving the resend email, timestamp update, logging,
and existing-invitation behavior.
🧹 Nitpick comments (2)
ddpui/utils/awsses.py (1)

16-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated credential-check logic between awsses.py and aws_client.py.

_ses_available() re-implements the exact SES_ACCESS_KEY_ID/SES_SECRET_ACCESS_KEY check already performed inside AWSClient._initialize_boto_session. If that env-var pair ever changes in aws_client.py, this check silently drifts and the DEBUG fallback could misbehave with no test catching it.

♻️ Suggested refactor: single source of truth
-def _ses_available() -> bool:
-    """True when real SES credentials are configured (the same pair AWSClient needs)."""
-    return bool(os.getenv("SES_ACCESS_KEY_ID") and os.getenv("SES_SECRET_ACCESS_KEY"))
+def _ses_available() -> bool:
+    """True when real SES credentials are configured."""
+    return AWSClient.has_credentials("ses")

Add a small has_credentials(cls, service_name) classmethod to AWSClient that checks the same env vars it already resolves per-service.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ddpui/utils/awsses.py` around lines 16 - 23, Remove the duplicated
environment-variable check from _ses_available and add or reuse
AWSClient.has_credentials("ses") as the single credential-availability source.
Implement has_credentials as a classmethod using the same per-service credential
resolution as AWSClient._initialize_boto_session, then have _ses_available
delegate to it while preserving the existing boolean behavior.
ddpui/api/admin_api.py (1)

344-361: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Sentinel id=0 for the "already-existing-user" invite branch is easy to misuse, and untested.

When the invitee already has a platform account, the endpoint returns a stub AdminInvitationSchema(id=0, ...) instead of a real invitation. Callers must special-case id == 0 to distinguish "added directly" from "pending invite created" — an implicit contract that's easy to miss on the frontend. No test in test_admin_api.py covers this branch either.

Consider adding an explicit discriminator (e.g. already_member: bool) instead of overloading id.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ddpui/api/admin_api.py` around lines 344 - 361, The existing-user invitation
response in the admin invitation flow should expose an explicit discriminator
instead of relying on the sentinel id=0. Add and populate an `already_member`
boolean on `AdminInvitationSchema`, set it for the no-invitation branch around
the `invitation is None` check, preserve the normal invitation behavior, and add
coverage in `test_admin_api.py` for this branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ddpui/api/admin_api.py`:
- Around line 80-89: Eliminate the per-organization queries in get_admin_orgs
and _admin_org_response by annotating each organization with its user count and
selecting or prefetching the related plan data in the queryset. Update
_admin_org_response to use those loaded values instead of calling
OrgUser.objects.filter(...).count() or org.base_plan(), while preserving the
existing AdminOrgSchema fields and response behavior.
- Around line 373-384: Update put_admin_org_user_role and
change_orguser_role_in_org to return a typed or sentinel permission error
instead of relying on the literal "Insufficient permissions" message. Determine
the HTTP status from that error type/sentinel, while preserving the existing 403
response for permission failures and 400 for other errors.
- Around line 124-151: Update post_admin_org and the
create_organization/create_org_plan flow so Airbyte workspace provisioning is
not left orphaned when plan creation fails: either move the remote provisioning
outside the outer transaction or explicitly delete the created workspace before
raising the plan error. Preserve atomic database behavior and the existing
cleanup on provisioning failure, while shortening the HTTP transaction scope
where possible.

---

Outside diff comments:
In `@ddpui/core/orguserfunctions.py`:
- Around line 200-219: Update the member lookup in the surrounding
delete-orguser flow to use case-insensitive email matching, consistent with
invite_user_to_org and change_orguser_role_in_org. Apply the same
case-insensitive matching to the Invitation cleanup query so differently-cased
payload.email values identify the existing user and remove pending invitations.
- Around line 283-297: Update the existing-invitation branch in
post_organization_user_invite_v1 to return a NewInvitationSchema-shaped result
instead of from_invitation(invitation), which produces InvitationSchema fields.
Match the response construction used by the other invite paths while preserving
the resend email, timestamp update, logging, and existing-invitation behavior.

---

Nitpick comments:
In `@ddpui/api/admin_api.py`:
- Around line 344-361: The existing-user invitation response in the admin
invitation flow should expose an explicit discriminator instead of relying on
the sentinel id=0. Add and populate an `already_member` boolean on
`AdminInvitationSchema`, set it for the no-invitation branch around the
`invitation is None` check, preserve the normal invitation behavior, and add
coverage in `test_admin_api.py` for this branch.

In `@ddpui/utils/awsses.py`:
- Around line 16-23: Remove the duplicated environment-variable check from
_ses_available and add or reuse AWSClient.has_credentials("ses") as the single
credential-availability source. Implement has_credentials as a classmethod using
the same per-service credential resolution as
AWSClient._initialize_boto_session, then have _ses_available delegate to it
while preserving the existing boolean behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2d0adb6d-d5ef-4a4c-bb3d-af2fdf618ba9

📥 Commits

Reviewing files that changed from the base of the PR and between f03abed and 5222d79.

📒 Files selected for processing (15)
  • ddpui/api/admin_api.py
  • ddpui/api/user_org_api.py
  • ddpui/auth.py
  • ddpui/core/orguserfunctions.py
  • ddpui/migrations/0169_org_is_active.py
  • ddpui/migrations/0170_orguser_is_active.py
  • ddpui/migrations/0171_invitation_invited_in_org.py
  • ddpui/models/org.py
  • ddpui/models/org_user.py
  • ddpui/routes.py
  • ddpui/tests/api_tests/test_admin_api.py
  • ddpui/tests/api_tests/test_user_org_api.py
  • ddpui/tests/core/test_auth.py
  • ddpui/tests/utils/test_awsses.py
  • ddpui/utils/awsses.py

Comment thread ddpui/api/admin_api.py Outdated
Comment thread ddpui/api/admin_api.py
Comment thread ddpui/api/admin_api.py Outdated
Comment on lines +373 to +384
@admin_router.put("/orgs/{org_id}/users/{orguser_id}/role", response=AdminOrgUserSchema)
@platform_admin_required
def put_admin_org_user_role(request, org_id: int, orguser_id: int, payload: AdminChangeRoleSchema):
"""Change a user's role in the org. Role-level cap skipped for the platform admin."""
org = _get_org_or_404(org_id)
orguser = _get_orguser_or_404(org, orguser_id)

_, error = orguserfunctions.change_orguser_role_in_org(
org, request.orguser, orguser.user.email, payload.role_uuid, is_platform_admin=True
)
if error:
raise HttpError(403 if error == "Insufficient permissions" else 400, error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n '"Insufficient permissions' ddpui/core/orguserfunctions.py

Repository: DalgoT4D/DDP_backend

Length of output: 337


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- ddpui/api/admin_api.py (around lines 360-390) ---'
sed -n '360,390p' ddpui/api/admin_api.py

echo
echo '--- ddpui/core/orguserfunctions.py (around lines 240-370) ---'
sed -n '240,370p' ddpui/core/orguserfunctions.py

echo
echo '--- tests / references to "Insufficient permissions" ---'
rg -n '"Insufficient permissions' ddpui tests || true

Repository: DalgoT4D/DDP_backend

Length of output: 8518


Avoid deriving the status code from the literal error string. change_orguser_role_in_org() returns "Insufficient permissions" for permission failures today, but HttpError(403 if error == "Insufficient permissions" else 400, error) makes the response depend on exact wording; a small message change would turn a permission error into a 400. Use a typed/sentinel error instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ddpui/api/admin_api.py` around lines 373 - 384, Update
put_admin_org_user_role and change_orguser_role_in_org to return a typed or
sentinel permission error instead of relying on the literal "Insufficient
permissions" message. Determine the HTTP status from that error type/sentinel,
while preserving the existing 403 response for permission failures and 400 for
other errors.

…andlers (DalgoT4D#1254)

Move the ORM and state-mutating logic out of the admin_api.py handlers into a
dedicated service layer, so the handlers follow the documented API-layer
contract (parse -> call service -> convert to response schema -> return) and
match how the invite/role-change/remove paths already delegate to
orguserfunctions.

New ddpui/core/admin/admin_service.py owns:
  - get_platform_stats, list_orgs, get_org, org_user_count
  - create_org (wraps orgfunctions.create_organization + create_org_plan)
  - update_org, set_org_active
  - get_orguser_in_org, list_org_users, list_org_invitations,
    get_pending_invitation, get_invitation_in_org, delete_invitation,
    set_orguser_active, removal_impact

admin_api.py now holds only HTTP concerns: request parsing, the platform-admin
gate, response-schema construction, 404/400 mapping, and logging of cross-org
actions. No behavior change; the service is HTTP-agnostic (returns models /
primitives) and imports nothing from admin_api, so there is no circular import.
core/admin/__init__.py is empty per the core-module convention.

Backend tests: ddpui/tests/api_tests/test_admin_api.py +
test_user_org_api.py — 96 passed.
…SET_NULL (DalgoT4D#1254)

The docstrings claimed removing an OrgUser cascade-deletes the dashboards and
charts they created. That contradicts the actual current behavior (and the
RemovalImpactSchema, the removal-impact endpoint, and the whole frontend
warning): Dashboard / Chart / ReportSnapshot.created_by are SET_NULL, so the
content is KEPT on removal and only the creator link is cleared. Access Control
v2 (PR DalgoT4D#1428) switched Dashboard & Chart from CASCADE to SET_NULL; ReportSnapshot
already was. Left uncorrected, these comments would mislead the next reader into
thinking removal destroys content.

Fixed three spots:
  - admin_api.py delete_admin_org_user docstring ("hard delete; cascades…")
  - orguserfunctions.delete_orguser_from_org docstring WARNING block
  - the inline "cascades their created content" comment above .delete()

Comment/docstring-only; no behavior change. (black also normalized three
adjacent over-length query/log lines the original commit left unwrapped.)

Backend tests: test_admin_api.py + test_user_org_api.py — 96 passed.
DalgoT4D#1254)

The handler mapped the role-change error to an HTTP status with
`403 if error == "Insufficient permissions" else 400` — a brittle match on a
human-readable message from the core function.

That 403 branch is also dead on this path: both "Insufficient permissions"
returns inside change_orguser_role_in_org are guarded by `not is_platform_admin`,
and the admin endpoint always calls it with is_platform_admin=True (the platform
admin acts cross-org and has no role-level cap). So the only errors reachable
here are "Invalid role" and "User does not exist", both bad requests.

Map any error to 400 and drop the string comparison entirely, with a comment
explaining why 403 is structurally unreachable. No behavior change (the 403
branch could never execute); the fragile match is gone. A broader typed
error-code contract across both callers (this + the single-org
post_modify_orguser_role) was left out to keep the change minimal, per scope.

Backend tests: test_admin_api.py — 27 passed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ddpui/core/admin/admin_service.py`:
- Around line 97-101: Update the organization partial-update logic at
ddpui/core/admin/admin_service.py:97-101 to build update_fields from the
provided name and viz_url arguments, call org.save(update_fields=update_fields)
only when non-empty, and remove the misleading exclusion comment. At
ddpui/core/admin/admin_service.py:106-107, save org_plans with
update_fields=["base_plan"]; at ddpui/core/admin/admin_service.py:117-118, save
org with update_fields=["is_active"].
- Around line 77-79: Update the create-organization flow around create_org_plan
so that when plan_error is present, the newly created org is deleted before
returning the error. Preserve the existing successful path and return values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e38d0f47-3b8c-449d-9d46-ef8e14650c3d

📥 Commits

Reviewing files that changed from the base of the PR and between 5222d79 and 44f76ff.

📒 Files selected for processing (4)
  • ddpui/api/admin_api.py
  • ddpui/core/admin/__init__.py
  • ddpui/core/admin/admin_service.py
  • ddpui/core/orguserfunctions.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • ddpui/api/admin_api.py
  • ddpui/core/orguserfunctions.py

Comment thread ddpui/core/admin/admin_service.py Outdated
Comment on lines +77 to +79
_, plan_error = orgfunctions.create_org_plan(create_payload, org)
if plan_error:
return None, plan_error

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Roll back the created organization if plan creation fails.

If create_org_plan fails, the Org record is currently left in the database. This contradicts the API layer's assumption that the transaction is fully rolled back upon failure. Furthermore, since orgfunctions.create_organization enforces unique organization names, the orphaned record will permanently block any future attempts to create an organization with the same name.

Add org.delete() to clean up the database state.

🐛 Proposed fix
     _, plan_error = orgfunctions.create_org_plan(create_payload, org)
     if plan_error:
+        org.delete()
         return None, plan_error
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_, plan_error = orgfunctions.create_org_plan(create_payload, org)
if plan_error:
return None, plan_error
_, plan_error = orgfunctions.create_org_plan(create_payload, org)
if plan_error:
org.delete()
return None, plan_error
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ddpui/core/admin/admin_service.py` around lines 77 - 79, Update the
create-organization flow around create_org_plan so that when plan_error is
present, the newly created org is deleted before returning the error. Preserve
the existing successful path and return values.

Comment thread ddpui/core/admin/admin_service.py Outdated
Veekshitha11 and others added 7 commits July 22, 2026 18:42
First slice of the independent admin session. issue_admin_session()
verifies credentials AND is_platform_admin before minting a distinct
admin token carrying a session="admin" claim (the artifact
AdminJwtAuthMiddleware will require, so a normal login token can never
satisfy the admin API). Non-admins and bad credentials get (None, error);
no token is issued.

TDD: refuses-non-admin and mints-with-claim both green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
issue_admin_session now stamps shorter admin lifetimes (access 15 min,
refresh 8 h) via new JWT_ADMIN_* settings, since the admin surface is
higher-privilege than the normal app. Bad-credentials path covered.

TDD: bad-password refused, lifetimes 900s/28800s asserted — all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the access-token cookie name to a class attribute in
CustomJwtAuthMiddleware (non-behavioral) so the admin portal can subclass
it. AdminJwtAuthMiddleware reads a separate admin_access_token cookie and
requires the session="admin" claim; a normal token (no claim) gets 401.
Everything else (orguser load, blacklist, 498/401) is inherited, so
@platform_admin_required keeps working.

Regression: existing auth suites (test_admin_api, test_auth, login/logout/
refresh) all green after the extraction; cookie name asserted unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the independent-session HTTP surface on admin_router:
- POST /login/  (auth=None): verifies creds + is_platform_admin, sets
  admin_access_token/admin_refresh_token; non-admin -> 403 (no cookie),
  bad password -> 401
- POST /logout/: blacklists + deletes only the admin_* cookies
- POST /token/refresh (auth=None): re-mints an admin access token,
  keeping the session="admin" claim; honors the logout blacklist
- GET /currentuser: identity for the frontend AdminGuard
- admin_router = Router(auth=AdminJwtAuthMiddleware()): every existing
  org/user route is now behind the admin session

Regression: test_admin_api (27), test_auth, and login/logout/refresh all
green — the shipped org/user handlers are unaffected (they're unit-tested
directly, and re-homing is at the router-auth layer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ce layer

B1: post_admin_token_refresh decoded the refresh token, validated the
session="admin" claim, hit Redis for the JTI blacklist and set the access
lifetime — all inside the endpoint. CLAUDE.md's layer table puts business
logic, ORM and external calls in core/, not the API layer, and the mirror-image
path (issue_admin_session) already lived in admin_service.

admin_service.refresh_admin_session() now owns that logic and returns the
repo's standard (result, error) tuple. The endpoint reads the cookie (an HTTP
concern, so it stays) and maps a non-null error to 401.

B6: post_admin_logout carried no @platform_admin_required, unlike every other
non-auth route in the file. Router-level auth proves a valid admin session but
not platform-admin status; added for consistency with the file's own pattern.

Drops the now-dead TokenError / RedisClient / timedelta imports from admin_api.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tClient

B3: tests/core/test_admin_auth.py held tests for four admin_api view functions
(login / logout / refresh / currentuser). This repo puts API-layer tests in
api_tests/test_<module>_api.py, and test_admin_api.py already existed. Moved
them there; test_admin_auth.py keeps only the middleware unit tests.

B2: the router-auth test used ninja's TestClient, which testing/SKILL.md and
api-tests.md both explicitly ban ("we don't use Ninja's TestClient") — it was
the only occurrence in the repo. Replaced with two HTTP-free tests that assert
the same guarantee more precisely: the router's auth is AdminJwtAuthMiddleware,
the only routes opting out are /login/ and /token/refresh (via auth_param), and
the middleware does not authenticate a request with no admin cookie.

B4: the moved tests used bare Mock() as the request, which api-tests.md lists in
its "Bad" column. They now use the mock_request(orguser) /
platform_admin_request helpers already defined in test_admin_api.py.

Adds coverage for the logout gate added in the previous commit.

B5: ran `uv run black .` (settings.py + two test files were unformatted). Root
cause: no pre-commit hook was installed in .git/hooks, so the configured black
hook never ran — fixed with `pre-commit install`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…st gaps (DalgoT4D#1254)

Convention review pass on the admin-portal backend (login/session + Users tab).
Backend only; webapp_v2 untouched.

Fixed (7 of 9 findings):
- Extract 11 schemas from admin_api.py into ddpui/schemas/admin_schema.py
  (API -> schemas/ convention; also unblocks typing the service signatures).
- Add ddpui/core/admin/exceptions.py; service raises typed exceptions instead of
  (value, error_string) tuples. API maps by exception TYPE, removing the fragile
  `error == "not a platform admin"` string match on the login path.
- Service signatures take payload schemas: issue_admin_session(AdminLoginSchema)
  and update_org(org, AdminUpdateOrgSchema), not loose args.
- Add from_model() to AdminOrgSchema/AdminOrgUserSchema; collapse 4 duplicated
  inline response constructions.
- invited_on stub uses timezone.now() (was naive datetime.now() under USE_TZ).
- Import canonical seed_db/mock_request from test_user_org_api instead of
  redefining them; drop the now-unused imports.
- Add the django.setup() bootstrap preamble to the three admin test files.

Deferred, with rationale (2 of 9):
- Core -> ddpui.auth import: auth.py is a shared foundation module that 4 other
  core modules already import from; relocating CustomTokenObtainSerializer is
  disproportionate and would break with precedent. Left as-is.
- api_response wrapper: routes return typed `response=` schemas per kpi_api /
  metric_api practice, and the cookie routes must return JsonResponse. Wrapping
  would break the typed contract and the webapp_v2 response shape.

Docstring fix:
- Correct stale create_org docstring/comments: on plan-creation failure the Org
  persists and the caller's @transaction.atomic rolls it back; only the Airbyte
  failure is undone inside create_organization.

Test coverage added (17 tests, admin suite 44 -> 61):
- refresh_admin_session refusal modes, logout JTI blacklisting, admin-cookie
  401-vs-498 mapping, login cookie security flags, cross-org isolation on the
  user routes, plan-failure rollback, and update_org / invitation-scoping /
  removal-impact edges.

black --check clean; pylint 7.20/10 (gate: fail-under 6.5); admin suite 61/61.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
ddpui/tests/api_tests/test_admin_api.py (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the duplicated Redis-mocking helper into a shared test fixture. All three files independently define an identical pair-of-patches helper (RedisClient.get_instance + set_roles_and_permissions_in_redis), each docstring noting no shared helper exists.

  • ddpui/tests/api_tests/test_admin_api.py#L882-887: replace _mock_auth_redis() with an import from a shared conftest/test-util fixture.
  • ddpui/tests/core/test_admin_auth.py#L39-44: replace _mock_auth_redis() with the same shared fixture.
  • ddpui/tests/core/test_admin_service.py#L37-42: replace _mock_redis() with the same shared fixture.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ddpui/tests/api_tests/test_admin_api.py` at line 1, Extract the duplicated
Redis patch helper into one shared test fixture or test utility, covering
RedisClient.get_instance and set_roles_and_permissions_in_redis. Replace
_mock_auth_redis in test_admin_api.py and test_admin_auth.py, and _mock_redis in
test_admin_service.py, with imports and usage of that shared fixture; remove the
local helper definitions and outdated docstrings.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@ddpui/tests/api_tests/test_admin_api.py`:
- Line 1: Extract the duplicated Redis patch helper into one shared test fixture
or test utility, covering RedisClient.get_instance and
set_roles_and_permissions_in_redis. Replace _mock_auth_redis in
test_admin_api.py and test_admin_auth.py, and _mock_redis in
test_admin_service.py, with imports and usage of that shared fixture; remove the
local helper definitions and outdated docstrings.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 92841ff9-32bf-4433-83d1-a7ad40cf2a5a

📥 Commits

Reviewing files that changed from the base of the PR and between 44f76ff and 7dd3ae8.

📒 Files selected for processing (9)
  • ddpui/api/admin_api.py
  • ddpui/auth.py
  • ddpui/core/admin/admin_service.py
  • ddpui/core/admin/exceptions.py
  • ddpui/schemas/admin_schema.py
  • ddpui/settings.py
  • ddpui/tests/api_tests/test_admin_api.py
  • ddpui/tests/core/test_admin_auth.py
  • ddpui/tests/core/test_admin_service.py

Veekshitha11 and others added 2 commits July 26, 2026 15:07
…ugh the service (DalgoT4D#1254)

Duplication audit follow-up on the admin-portal backend. Every finding was checked
against the repo's own convention docs (typed-schemas, service-delegation,
coding-standards, api-endpoint) before acting. Backend only; webapp_v2 untouched,
and no shared module was modified — LoginPayload, NewInvitationSchema,
orguserfunctions, orgfunctions and user_org_api are imported and called, never edited.

Applied (audit items 1, 2, 3, 6, 7, 11, 16):
- Delete AdminInviteUserSchema; the invite route takes the existing
  NewInvitationSchema directly. The two were identical field-for-field and the
  handler remapped one into the other. (typed-schemas Step 1: "Don't define a new
  schema if one exists.")
- Delete AdminLoginSchema; post_admin_login and issue_admin_session take the
  existing LoginPayload, same {username, password} shape as the normal login.
  (typed-schemas Step 1.)
- Add AdminInvitationSchema.from_model(); replaces two duplicated inline response
  constructions in admin_api.py. (api-endpoint rule 8: "from_model() on every
  response schema.")
- create_org() now takes the AdminCreateOrgSchema the API validated and widens it
  to CreateOrgSchema inside the service, instead of the handler hand-building a
  6-field payload the service was typed against. (api-endpoint rule 5: the API
  function only parses, calls the service, converts and returns; also the stated
  intent of admin_schema.py's own module docstring.)
- Add admin_service.invite_user / change_orguser_role / remove_orguser as thin
  delegations to orguserfunctions with is_platform_admin=True. The three Users-tab
  routes now go through the service rather than calling orguserfunctions directly,
  so admin_api no longer imports it. Behaviour is unchanged — the org-parameterized
  core functions are still the single source of truth. (service-delegation; matches
  admin_service.py's "handlers stay thin" docstring.)
- Add response= typing to the four untyped non-cookie routes (currentuser, ping and
  the two deletes) via AdminCurrentUserSchema / AdminPingSchema / AdminSuccessSchema.
  success stays an int so the {"success": 1} wire format is byte-identical. The three
  cookie-setting routes keep returning JsonResponse untyped.
- Move _get_org_or_404 above its first call site; it sat under the Users-tab banner
  while serving the org routes 40 lines above it.

Not changed, by design:
- AdminCreateOrgSchema, AdminOrgSchema, AdminOrgUserSchema, AdminUpdateOrgSchema,
  AdminChangeRoleSchema, AdminStatsSchema and RemovalImpactSchema are genuinely
  distinct, not duplicates. AdminCreateOrgSchema narrows CreateOrgSchema by 8 fields
  (slug, airbyte_workspace_id, is_demo...) and subclassing can only widen, so a
  separate input schema is correct. AdminOrgUserSchema.is_active is the new per-org
  OrgUser.is_active, not OrgUserResponse.active (the global User.is_active) —
  reusing it would return the wrong flag. AdminInvitationSchema omits invite_code,
  which is the secret that grants org access.
- Migrations 0169/0170/0171 are already correct: exactly one migration per field,
  linear chain, single leaf.

Deferred (need a webapp_v2 dependency check first):
- Renaming AdminInvitationSchema.invited_role_slug to match the established
  invited_new_role_slug.
- Renaming RemovalImpactSchema to AdminRemovalImpactSchema for module consistency.
- The id=0 stub invitation returned when the invitee already had a platform account;
  a client cannot currently distinguish "invited" from "added directly" except by
  that undocumented sentinel.

Verification:
- Admin suite 61/61, unchanged from before the refactor.
- Full unit suite (ddpui/tests less integration_tests): 2252 passed, 2 skipped.
  The 11 failures are pre-existing and unrelated — transform/dbt/reports/elementary
  modules — and reproduce identically on a clean HEAD (10 failed + 1 passed in
  isolation on both trees).
- test_user_org_api.py 69/69 with zero modifications, covering the normal login and
  invite paths that now share LoginPayload and NewInvitationSchema.
- black --check clean; pylint 7.34/10 on the admin modules (gate: fail-under 6.5),
  no new findings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…/api/v2/login/ (DalgoT4D#1254)

The admin portal had its own session: an AdminJwtAuthMiddleware reading distinct
admin_access_token / admin_refresh_token cookies, minted by its own login / logout /
token-refresh endpoints. That is now removed. The admin app authenticates through the
shared POST /api/v2/login/ and authority comes solely from @platform_admin_required on
each route. Backend half; webapp_v2 changes ship alongside.

Removed:
- ddpui/auth.py: AdminJwtAuthMiddleware. Its 401/498 cookie handling was inherited from
  CustomJwtAuthMiddleware and is unaffected. The cookie_name hook stays (the base class
  still uses it) but no longer advertises a subclass.
- ddpui/api/admin_api.py: post_admin_login, post_admin_logout, post_admin_token_refresh,
  _set_admin_cookie. admin_router is now a bare Router() inheriting the API-wide
  CustomJwtAuthMiddleware — matching all 24 sibling routers, none of which bind their own
  auth.
- ddpui/core/admin/admin_service.py: issue_admin_session, refresh_admin_session.
- ddpui/core/admin/exceptions.py: AdminInvalidCredentialsError, AdminNotPlatformAdminError,
  AdminSessionError. AdminServiceError and AdminOrgCreateError stay.
- ddpui/settings.py: JWT_ADMIN_ACCESS_TOKEN_EXPIRY_MINUTES, JWT_ADMIN_REFRESH_TOKEN_EXPIRY_HOURS.

Added:
- ddpui/routes.py: drf_authentication_failed_handler maps DRF's AuthenticationFailed to
  401. CustomTokenObtainSerializer is a DRF serializer, but these are ninja views, so
  DRF's own handler never runs and a wrong password fell through to the generic Exception
  handler as a 500. AuthenticationFailed already declares status_code == 401; nothing was
  reading it. This fixes the normal product login too, not just the admin one.

Kept deliberately:
- GET /api/v1/admin/currentuser, now gated by @platform_admin_required over the shared
  cookie. NOT replaced by /api/currentuserv2: that returns a LIST of OrgUsers and is gated
  on the per-org can_view_orgusers permission, which a platform admin need not hold — it
  would lock a legitimate admin out of the portal.
- Admin logout was deleted outright rather than repointed: no caller exists (there is no
  logout control anywhere under components/admin).

Reuse over new code (per the standing convention rule):
- The shared POST /api/v2/login/ and lookup_user() are called as-is; no admin login logic
  was rewritten.
- lookup_user()'s return is deliberately NOT typed into a schema. v1 login mutates that
  dict (retval["token"] = ...), so a Pydantic model would break it, and v2 returns a
  JsonResponse which ninja passes through unvalidated — a `response=` there would enforce
  nothing. Pinned with tests instead (both the True and False cases).
- Tests reuse the existing inline patch("ddpui.auth.RedisClient.get_instance") idiom; no
  new mock helper was introduced.

Deviations from the agreed plan, both intentional:
- lib/api.ts keeps isAdminPath + adminAwareLoginPath (the plan said delete all three
  helpers). Only adminAwareRefreshEndpoint was actually dead. Deleting adminAwareLoginPath
  would send an admin whose session expires to /login instead of /admin/login — a UX
  regression unrelated to this change. (webapp_v2 side.)
- test_auth.py GAINED two tests rather than the coverage being dropped. Deleting
  test_admin_auth.py wholesale would have removed the only coverage of
  CustomJwtAuthMiddleware.__call__ — the cookie path mapping malformed->401 and
  expired->498. test_auth.py had none; that code is shared and live on every
  authenticated route, so the two tests were ported onto the normal access_token cookie.
  None of test_auth.py's existing 9 tests were touched.

Test impact (27 session-tied tests removed, 8 added, net -19):
- tests/core/test_admin_auth.py: deleted (6 tests, all AdminJwtAuthMiddleware).
- tests/core/test_admin_service.py: 13 -> 5. The 8 issue_admin_session /
  refresh_admin_session tests went with the functions; org / invitation / removal-impact
  coverage is untouched.
- tests/api_tests/test_admin_api.py: 42 -> 35. 13 session tests deleted; 3 rewritten (the
  router now asserts it has NO auth of its own and that no route opts out, plus
  currentuser refusing a signed-in non-admin and resolving a platform admin); 3 new —
  is_platform_admin present and True on the v2 login body, is_platform_admin False for a
  normal user (the negative half the admin form refuses on), and bad credentials mapping
  to 401 rather than 500.
- tests/core/test_auth.py: 9 -> 11 (+2 ported). Both assert the message as well as the
  status, so a 401/498 raised for some other reason cannot pass.

Verification:
- Full backend unit suite (ddpui/tests less integration_tests): 2233 passed, 2 skipped,
  11 failed. All 11 are pre-existing and unrelated (transform / dbt / elementary /
  reports); 9 reproduce identically on a clean HEAD.
- The reports/ failures are order-dependent flakes on comment timestamps, not regressions:
  across runs the failing set differs each time, the count moves between 11 and 12, and
  the reports/ directory passes 173/173 on this branch while failing 3 at HEAD.
  Worth its own ticket; out of scope here.
- test_user_org_api.py: 69/69, unmodified — it covers the normal login and invite paths
  this change now routes the admin app through.
- black --check clean; no new pylint findings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ddpui/auth.py (1)

161-189: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Skip per-org deactivation checks for platform admins.

When no x-dalgo-org header is present, OrgUser.objects.filter(user=request.user).first() can resolve to any OrgUser row; for @platform_admin_required routes, platform_admin_required never gets a chance to grant cross-org/Dalgo-ops authority because the middleware has already raised 403 for an unrelated deactivated org/membership. Gate these checks with UserAttributes.is_platform_admin or route platform-admin traffic through an exempt credential path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ddpui/auth.py` around lines 161 - 189, The org and membership deactivation
checks in the token-authentication flow must not block platform admins when no
x-dalgo-org header is provided. Use the existing
UserAttributes.is_platform_admin signal to bypass the orguser.org.is_active and
orguser.is_active checks for platform-admin users, while preserving both 403
checks for non-admins and requests scoped to a specific organization.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@ddpui/auth.py`:
- Around line 161-189: The org and membership deactivation checks in the
token-authentication flow must not block platform admins when no x-dalgo-org
header is provided. Use the existing UserAttributes.is_platform_admin signal to
bypass the orguser.org.is_active and orguser.is_active checks for platform-admin
users, while preserving both 403 checks for non-admins and requests scoped to a
specific organization.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 98c48419-42ce-4e81-be23-1bc9811bbea8

📥 Commits

Reviewing files that changed from the base of the PR and between 7dd3ae8 and eca9865.

📒 Files selected for processing (9)
  • ddpui/api/admin_api.py
  • ddpui/auth.py
  • ddpui/core/admin/admin_service.py
  • ddpui/core/admin/exceptions.py
  • ddpui/routes.py
  • ddpui/schemas/admin_schema.py
  • ddpui/tests/api_tests/test_admin_api.py
  • ddpui/tests/core/test_admin_service.py
  • ddpui/tests/core/test_auth.py
💤 Files with no reviewable changes (1)
  • ddpui/core/admin/exceptions.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • ddpui/tests/core/test_auth.py

…n API

Removed the ability for platform admins to deactivate/reactivate an org
or a per-org user, end to end:
- 4 endpoints: POST /orgs/{id}/deactivate, /reactivate,
  /orgs/{id}/users/{id}/deactivate, /reactivate
- 2 service functions: set_org_active(), set_orguser_active()
- the is_active field on AdminOrgSchema and AdminOrgUserSchema

Why: activate/deactivate was descoped from the current admin-portal
milestone. It may come back in a later milestone.

Deliberately kept: Org.is_active and OrgUser.is_active model fields,
and the permission-load enforcement in auth.py that blocks access
based on those flags (auth.py:172-188). That enforcement is safety
infrastructure independent of whether an admin UI can toggle the
flags, so it stays live even with the toggle removed.

Files:
- ddpui/api/admin_api.py
- ddpui/core/admin/admin_service.py
- ddpui/schemas/admin_schema.py
- ddpui/tests/api_tests/test_admin_api.py

Verified: full suite (excluding ddpui/tests/integration_tests, which
need external Postgres/BigQuery creds not present locally) run against
a live Postgres instance. 2229 passed, 2 skipped, 13 failed. Zero
failures touch admin/org code. All 13 failures traced to pre-existing/
unrelated causes: 12 reproduce identically against a clean pre-removal
baseline (test_transform_api.py x4, test_mention_notifications.py x2,
test_mention_service.py::test_truncates_long_content,
test_elementary_service.py x2, test_dbtfunctions.py x1,
test_dbtproject.py x2); the 13th
(test_mention_service.py::test_returns_prior_comments) is order-
dependent full-suite flakiness with no import path to the changed
files, confirmed by passing 17/17 when its file is run alone on both
the baseline and this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@siddhant3030

Copy link
Copy Markdown
Contributor

Review — two blocking items before merge:

  1. Deactivation is enforceable but not actionable. The PR description says M3/M4 include org/user deactivate, and the enforcement layer is all here (Org.is_active, OrgUser.is_active, migrations 0169/0170, the middleware 403s) — but no endpoint ever sets either flag, and neither AdminOrgSchema nor AdminOrgUserSchema exposes is_active, so the portal can't set or display active status (the get_admin_org_users docstring even promises "per-org Status"). If deactivation was deliberately deferred to a follow-up PR, please update the PR body and docstring; otherwise the endpoints are missing.

  2. Orphaned Airbyte workspace when plan creation fails. In post_admin_org, @transaction.atomic rolls back the Org row when create_org_plan fails, but create_organization has already provisioned a real Airbyte workspace — an external side effect the DB rollback can't undo, leaving a leaked workspace with no Org pointing at it. Either delete the workspace in the plan-failure path, or log the workspace ID loudly for cleanup.

Comment thread ddpui/api/admin_api.py Outdated

@admin_router.get("/ping", response=AdminPingSchema)
@platform_admin_required
def get_admin_ping(request):

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.

we dont need this. @Veekshitha11

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed /admin/ping and AdminPingSchema. It was only there to exercise the platform-admin gate in tests, so I repointed those 3 tests at /admin/currentuser (a real endpoint with the same gate) instead of dropping the coverage.

Comment thread ddpui/core/admin/admin_service.py Outdated

def create_org(payload: AdminCreateOrgSchema) -> Org:
"""
Create an org and its plan. Takes the AdminCreateOrgSchema the API validated — the

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.

can we add only 2 lines of comments here? we dont need this long comment for every functions

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done — trimmed every function docstring in this file (and admin_api.py, which had the same style) down to 1-2 lines.

import django.db.models.deletion


def backfill_invited_in_org(apps, schema_editor):

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.

why do we need this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good question — this is needed because a platform admin invites cross-org: the admin isn't a member of the target org, so invited_by.org (the admin's own org) isn't the target org. Without invited_in_org, accept/cancel and the per-org pending-invites list would resolve to the wrong org. Trimmed the comment block down per your other note.

Comment thread ddpui/schemas/admin_schema.py Outdated
is_platform_admin: bool


class AdminPingSchema(Schema):

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.

we dont need this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed, along with the /admin/ping endpoint that used it.

Comment thread ddpui/schemas/admin_schema.py Outdated
)


class AdminCreateOrgSchema(Schema):

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.

is this extending current create org schema?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It wasn't before — now it does. Made AdminCreateOrgSchema(CreateOrgSchema) a real subclass overriding just base_plan/can_upgrade_plan/subscription_duration/superset_included with admin-friendly defaults, so admin_service.create_org can pass the payload straight through instead of manually rebuilding a CreateOrgSchema.

Comment thread ddpui/utils/awsses.py Outdated
return AWSClient.get_instance("ses")


def _ses_available() -> bool:

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.

what is this?

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.

and why do we need it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is a local-dev convenience: _ses_available() checks whether real SES credentials are configured. When they're not (and DEBUG=True), send_text_message logs the email instead of trying to call real SES, so invite/signup/password-reset flows work on a laptop without needing AWS SES set up. It's off in staging/prod since DEBUG is off there, so it can't mask a real misconfiguration. There's a dedicated test for it. Shortened the docstrings per your other comment.

Comment thread ddpui/utils/awsses.py
return bool(os.getenv("SES_ACCESS_KEY_ID") and os.getenv("SES_SECRET_ACCESS_KEY"))


def send_text_message(to_email, subject, message):

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.

same question here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Same answer as above — send_text_message is the one function that uses this local-dev fallback (the other send_* helpers always hit real SES). Docstring trimmed.

…rely (DalgoT4D#1254)

Mentor review (Siddhant) flagged that the activate/deactivate MIGRATION
was still present after the UI-only removal in 20720fe/07bdace8 — the
columns and the auth.py permission-load enforcement were deliberately
kept live with no toggle left to drive them. This completes that
removal properly (Option B: full removal, not re-hiding), rather than
leaving dead schema + enforcement around.

Removed:
- Org.is_active and OrgUser.is_active model fields
- The auth.py permission-load enforcement that read them
  (org-deactivated / per-org-deactivated 403 checks, CustomJwtAuthMiddleware)
- Migration 0172 drops both columns (reverse of 0169_org_is_active /
  0170_orguser_is_active)

Tests:
- Deleted the 4 auth.py enforcement tests (blocks_deactivated_org,
  allows_reactivated_org, blocks_deactivated_orguser,
  allows_active_orguser) — nothing left for them to prove
- Rewrote 2 vestigial is_active references in test_admin_api.py
  (test_admin_list_orgs, test_admin_edit_org_locks_slug) that predated
  this cleanup; both cover unrelated behavior and are kept

Capability gap (accepted, not a regression to fix later): there is no
reversible per-org or per-user suspend left. The only remaining levers
to stop someone from logging in are removing their OrgUser membership
outright, flipping the global User.is_active (blocks every org, not
just one), or deleting the org itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Veekshitha11

Copy link
Copy Markdown
Collaborator Author

Addressed both:

  1. Deactivation — removed is_active entirely rather than building the missing endpoint (migration 0172_remove_org_is_active_orguser_is_active), since it wasn't part of what shipped. Also caught a stale get_admin_org_users docstring still promising "per-org Status" and fixed it. Updating the PR description to drop the stale deactivate mentions.
  2. Orphaned Airbyte workspaceadmin_service.create_org now calls airbyte_service.delete_workspace(...) when create_org_plan fails, before raising, so the workspace doesn't outlive the rolled-back Org row. A delete failure is logged loudly rather than silently swallowed.

Replied inline on the rest of the comments too.

Veekshitha11 and others added 11 commits August 17, 2026 20:38
- Remove /admin/ping + AdminPingSchema (test-only stub); repoint its 3 guard
  tests at /admin/currentuser instead of dropping coverage
- Fix orphaned Airbyte workspace: create_org now deletes the workspace when
  plan creation fails, since @transaction.atomic can't undo that external
  side effect; add test coverage for it
- Make AdminCreateOrgSchema a real subclass of CreateOrgSchema instead of a
  parallel schema, so admin_service.create_org can drop the manual rebuild
- Trim verbose per-function docstrings across admin_api.py/admin_service.py
  to 1-2 lines; shorten the invited_in_org migration comment + help_text
  (kept the field -- needed for cross-org admin invites)
- Fix get_admin_org_users docstring still promising "per-org Status" after
  is_active was removed
…lback (DalgoT4D#1254)

026decf already removed the Org.is_active / OrgUser.is_active model
fields and the auth.py permission-load enforcement that read them
(mentor review from Siddhant), but did so by adding migration 0172 to
reverse 0169_org_is_active / 0170_orguser_is_active rather than
deleting them. PR DalgoT4D#1432 (this branch -> main) is still open and
unmerged, confirmed via `gh pr view` — so 0169/0170 have never applied
outside this branch, on any database. Reversing them with a fourth
migration is unnecessary ceremony for a feature nothing has ever run
against; deleting the add-migrations outright leaves no trace of the
capability ever existing in migration history, which is the actual
goal (Option B: full removal, not re-hiding).

Removed:
- Migrations 0169_org_is_active, 0170_orguser_is_active, and
  0172_remove_org_is_active_orguser_is_active (all three deleted, not
  reversed)
- The AWS SES local-dev fallback in awsses.py (send_text_message
  silently logged instead of sending when DEBUG=True and SES creds
  were absent). Without it, missing credentials raise loudly and
  immediately via AWSClient's ValueError - never silently swallowed,
  in dev or prod.

Migration chain repoint: 0171_invitation_invited_in_org depended on
0170; since 0169 and 0170 are both gone, it now depends on 0168
(the nearest surviving migration), not 0169 (0170's own former
dependency, also deleted). Chain is now 0168 -> 0171, confirmed via
Django's MigrationLoader graph: single leaf node, no dangling refs.

Tests:
- Deleted the 3 dedicated SES-fallback tests in test_awsses.py
- Rewrote the Week-1 E2E admin test (test_admin_api.py) to mock
  send_invite_user_email via @patch, the same documented stencil
  used by 4 other tests in this file and by .claude/skills/testing/
  mocking.md, instead of relying on the now-removed fallback

Capability gap (unchanged from 026decf, still accepted): there is no
reversible per-org or per-user suspend left. The only remaining levers
to stop someone from logging in are removing their OrgUser membership
outright, flipping the global User.is_active (blocks every org, not
just one), or deleting the org itself.

Verified this pass, against a live Postgres (dalgo-postgres, :5433):
- test_admin_api.py: 33/33 passed, including the rewritten E2E test
- test_awsses.py: 4/4 passed
- Full suite (ddpui/tests, excluding integration_tests): stable at
  ~2220-2226 passed across repeated runs; the only variance is a
  pre-existing order-dependent flake in test_mention_notifications.py
  / test_mention_service.py, reproduced identically on unmodified
  HEAD and unrelated to this change
- manage.py check: clean; black --check: clean

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…algoT4D#1254)

Siddhant flagged a migration conflict on this PR. Investigating scoped
to migration files only (no rebase): this branch's own
0171_invitation_invited_in_org.py collided by number with a real,
different migration already merged to main —
0171_orgwarehouse_org_unique.py (part of 8 migrations, 0169-0176, that
landed on main after this branch forked, none related to admin-portal).
Same migration number, unrelated content — a straight name collision,
not a harmless numbering gap.

Fix: renamed the file to 0177_invitation_invited_in_org.py, the next
number after main's current tip (0176_trialsignup), so it no longer
collides with anything that exists on main today.

Dependency stays on 0168_alter_alert_created_by_alter_chart_created_by
_and_more, NOT 0176_trialsignup. Pointing at 0176 was the original plan
but is structurally impossible on this branch as it stands: Django
builds the migration graph purely from files present in the current
checkout, never from the database or another branch, and
0169-0176 don't exist here (they're main-only files; bringing them in
would be the rebase this pass is explicitly not doing). Confirmed this
concretely, not just asserted it - pointing the dependency at
0176_trialsignup made both `MigrationLoader` and `manage.py
showmigrations` fail immediately with `NodeNotFoundError: ... references
nonexistent parent node ('ddpui', '0176_trialsignup')`, before any
database was involved. `manage.py check` alone did NOT catch this - it
doesn't run full migration-graph validation by default, so it isn't
sufficient on its own for verifying a migration dependency change.

0168 is the actual last-common-ancestor migration present in this
branch's tree, so that's what 0177 depends on instead. This does mean
an eventual real merge/rebase will still need Django's standard
`makemigrations --merge` step to reconcile this branch's 0177 against
main's own chain through 0176 - that's normal, expected behavior for
two branches extending migrations in parallel off the same parent, not
a defect in this fix.

Diff is a pure rename: `git diff -M` reports 100% similarity, zero
content changes - the AddField operation, help text, and the entire
backfill_invited_in_org function body are byte-for-byte identical to
what 0171 was; only the filename changed.

Verified against a real database, not just the graph in isolation:
- Built a scratch Postgres DB (migration_verify_test) on the same
  dalgo-postgres container used for prior verification
- Checked out upstream/main in a temporary git worktree and applied
  its full migration history to that DB - all migrations through the
  real 0176_trialsignup applied clean
- Switched back to this branch and ran
  `manage.py migrate ddpui 0177_invitation_invited_in_org` against
  that same database: applied clean
  ("Applying ddpui.0177_invitation_invited_in_org... OK")
- Confirmed in Postgres directly: invited_in_org_id column present
  with the correct FK to ddpui_org(id), and django_migrations shows
  0177_invitation_invited_in_org recorded immediately after main's
  real 0176_trialsignup
- Worktree and scratch DB removed afterward; this repo's .env
  untouched throughout

Scope: migration file only. auth.py, admin_api.py, admin_service.py,
and all test files are untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lgoT4D#1254)

The existing docstring covers what the function does (copy invited_by.org
onto invited_in_org); it didn't explain why the backfill is needed at all.
Adds that: Invitation has been a live table since 0001_initial.py, so real
pending invites already exist independent of this branch, and would get
invited_in_org=NULL without this backfill. accept_invitation_v1 already
falls back to invited_by.org when null, but backfilling sets the real
value permanently instead of leaning on that fallback indefinitely. Also
notes why invited_by.org is provably correct for every existing row: the
cross-org mismatch this field exists for is only reachable through the
new admin-portal invite path this branch introduces.

Comment only — backfill_invited_in_org's logic, the field definition, and
the migration's dependency are all unchanged from ac1dd05.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
features/admin-portal/plan.md was flattened from v1/plan.md in
dalgo-core commit 2f99890, before tonight's session. Two code comments
still pointed at the old path. Comment text only, no logic change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reuses OrgCleanupService (the same cleanup the deleteorg management
command runs) via a new GET delete-impact + DELETE /orgs/{id} pair,
gated by the existing @platform_admin_required guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Milestone 3 groundwork (per-org and multi-org feature flags). Both build
on the existing enable/disable_feature_flag primitives:
- clear_org_flag deletes an org's override row instead of writing False,
  so get_all_feature_flags_for_org falls back to the global default.
- bulk_set_feature_flag applies the same on/off change to many orgs,
  best-effort (mirrors the per-recipient loop in
  notifications_functions.create_notification, not @transaction.atomic).
  Every result is {org_id, success} only, with no message field, so a
  bulk request can't be used to tell a nonexistent org apart from any
  other per-org failure (plan.md §5).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Milestone 3. New routes on admin_router, all gated by the existing
@platform_admin_required (no new auth surface):

  GET    /admin/flags/catalog                the FEATURE_FLAGS registry
  GET    /admin/orgs/{id}/flags              global default + org override, merged
  PUT    /admin/orgs/{id}/flags/{name}       set on/off for one org
  DELETE /admin/orgs/{id}/flags/{name}       clear the org's override
  PUT    /admin/flags/{name}/orgs            bulk on/off for several orgs at once

The bulk route validates flag_name once, up front (400 for the whole
request on an unknown flag), then delegates per-org to
bulk_set_feature_flag — best-effort, so one bad org_id in the batch
doesn't block the others. Its response is exactly {org_id, success}
per org, with no message field, so a failed org_id can never be told
apart from any other failure cause (plan.md §5).

OrgFeatureFlag is reused as-is; no migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Broadcast notifications (Milestone 2): platform admins compose, preview a
combined recipient count, and send immediately to the platform, one org, or
several selected orgs, choosing in-app and/or email per broadcast; review
sent broadcasts.

- Migration 0178: additive target_org_ids/send_in_app/send_email on
  Notification (defaults preserve today's behavior for every existing row)
- get_recipients gains an additive org_slugs param, merging recipients
  across a multi-org audience into one list
- handle_recipient gates email on notification.send_email in addition to
  the recipient's own opt-in
- create_notification/NotificationDataSchema persist the new fields at
  creation time, before handle_recipient runs
- fetch_user_notifications, fetch_user_notifications_v1,
  get_unread_notifications_count, mark_all_notifications_as_read each gain
  a notification__send_in_app=True filter
- New admin routes: POST /admin/notifications/preview, POST
  /admin/notifications, GET /admin/notifications, all @platform_admin_required
- 31 new tests across test_notification_models.py, test_notifications_service.py,
  test_admin_service.py, test_admin_api.py

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…read endpoint

Removed: the PUT /flags/{flag_name}/orgs bulk route (put_admin_bulk_flag),
its bulk_set_org_flags service wrapper, and the bulk_set_feature_flag
utils impl, plus their schemas (AdminBulkSetFlagSchema,
AdminBulkFlagResultItem) and all 6 dedicated tests. Confirmed dead: no
caller anywhere in DDP_backend or webapp_v2 besides this route/its own
tests -- the portal-wide Feature Flags page that used to drive it now
toggles one org at a time instead.

Added: GET /flags/{flag_name}/orgs (get_admin_flag_orgs), a read-only
route returning every org's current status for one flag
({org_id, org_name, enabled}). The service layer
(get_flag_status_for_orgs) reuses get_org_flags per org rather than
duplicating flag-resolution logic, ordered by org name. Powers the new
table on the frontend; the actual per-org toggle still goes through the
existing PUT /orgs/{org_id}/flags/{flag_name}, unchanged.

Verification: full suite green (2273 passed, 11 pre-existing failures
in unrelated modules -- transform_api, mention_notifications/service,
elementary_service, dbtfunctions, dbt_automation -- none touching
admin/feature-flags code). Grepped for every removed symbol across both
repos; only remaining hits are in dalgo-core's planning docs, not code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Five fixes from a convention/duplication audit of the admin portal work.

- Reject a broadcast with both channels off. Previously only the composer
  enforced it, so a direct POST persisted a Notification plus a recipient
  row per user and delivered to nobody.
- Resolve the portal-wide flag table in one query. get_flag_status_for_orgs
  called get_org_flags per org, costing a query per org. The batching lives
  in utils/feature_flags.get_flag_value_for_orgs so the global-default /
  org-override rule stays in the module that owns it, rather than being
  rebuilt in the service. Guarded by a query-count test.
- delete_org_impact returns OrgDeletionImpactSchema instead of a positional
  7-tuple of same-typed ints, where a reordering would have been silent.
- Drop two currentuser tests duplicating assertions already made at the top
  of the same file; the later pair was strictly weaker.
- Correct comments describing a multi-org flag write that no longer exists
  (every flag write is single-org; the portal-wide view is a read).

Co-Authored-By: Claude Opus 5 (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.

2 participants