Admin Portal v1 (Week 1): platform-admin gate, org management, users tab (Issue #1254) - #1432
Admin Portal v1 (Week 1): platform-admin gate, org management, users tab (Issue #1254)#1432Veekshitha11 wants to merge 33 commits into
Conversation
…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>
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
💤 Files with no reviewable changes (2)
WalkthroughAdds 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. ChangesAdmin portal lifecycle
Estimated code review effort: 4 (Complex) | ~60 minutes Merge Risk: ⚪ Minimal · up to 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: 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
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1⚔️ Resolve merge conflicts 💡
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 winEmail lookup uses exact match, not
__iexact/normalized — inconsistent with sibling functions.
user__email=payload.email(line 200-202) andinvited_email=payload.email(line 217-219) do exact-string matching, unlikeinvite_user_to_org(user__email__iexact=invited_email, line 255) andchange_orguser_role_in_org(user__email__iexact=request_email, line 358) in this same file. Since storedUser.emailis always lowercased on creation, a caller passing a differently-cased email (e.g. from a user-suppliedDeleteOrgUserPayload.emailvia the single-orgdelete_orguser_v1wrapper) 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 winResend branch returns
InvitationSchemaunder aNewInvitationSchemaendpoint.post_organization_user_invite_v1declaresresponse=NewInvitationSchema, but this branch returnsfrom_invitation(invitation), andfrom_invitation()buildsInvitationSchemafields (invited_by,invited_on,invite_code,invited_new_role_slug). This can break response serialization; return the sameNewInvitationSchemashape 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 winDuplicated credential-check logic between
awsses.pyandaws_client.py.
_ses_available()re-implements the exactSES_ACCESS_KEY_ID/SES_SECRET_ACCESS_KEYcheck already performed insideAWSClient._initialize_boto_session. If that env-var pair ever changes inaws_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 toAWSClientthat 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 winSentinel
id=0for 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-caseid == 0to distinguish "added directly" from "pending invite created" — an implicit contract that's easy to miss on the frontend. No test intest_admin_api.pycovers this branch either.Consider adding an explicit discriminator (e.g.
already_member: bool) instead of overloadingid.🤖 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
📒 Files selected for processing (15)
ddpui/api/admin_api.pyddpui/api/user_org_api.pyddpui/auth.pyddpui/core/orguserfunctions.pyddpui/migrations/0169_org_is_active.pyddpui/migrations/0170_orguser_is_active.pyddpui/migrations/0171_invitation_invited_in_org.pyddpui/models/org.pyddpui/models/org_user.pyddpui/routes.pyddpui/tests/api_tests/test_admin_api.pyddpui/tests/api_tests/test_user_org_api.pyddpui/tests/core/test_auth.pyddpui/tests/utils/test_awsses.pyddpui/utils/awsses.py
| @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) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n '"Insufficient permissions' ddpui/core/orguserfunctions.pyRepository: 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 || trueRepository: 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.
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
ddpui/api/admin_api.pyddpui/core/admin/__init__.pyddpui/core/admin/admin_service.pyddpui/core/orguserfunctions.py
🚧 Files skipped from review as they are similar to previous changes (2)
- ddpui/api/admin_api.py
- ddpui/core/orguserfunctions.py
| _, plan_error = orgfunctions.create_org_plan(create_payload, org) | ||
| if plan_error: | ||
| return None, plan_error |
There was a problem hiding this comment.
🗄️ 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.
| _, 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.
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>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
ddpui/tests/api_tests/test_admin_api.py (1)
1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExtract 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
📒 Files selected for processing (9)
ddpui/api/admin_api.pyddpui/auth.pyddpui/core/admin/admin_service.pyddpui/core/admin/exceptions.pyddpui/schemas/admin_schema.pyddpui/settings.pyddpui/tests/api_tests/test_admin_api.pyddpui/tests/core/test_admin_auth.pyddpui/tests/core/test_admin_service.py
…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>
There was a problem hiding this comment.
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 liftSkip per-org deactivation checks for platform admins.
When no
x-dalgo-orgheader is present,OrgUser.objects.filter(user=request.user).first()can resolve to any OrgUser row; for@platform_admin_requiredroutes,platform_admin_requirednever 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 withUserAttributes.is_platform_adminor 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
📒 Files selected for processing (9)
ddpui/api/admin_api.pyddpui/auth.pyddpui/core/admin/admin_service.pyddpui/core/admin/exceptions.pyddpui/routes.pyddpui/schemas/admin_schema.pyddpui/tests/api_tests/test_admin_api.pyddpui/tests/core/test_admin_service.pyddpui/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>
|
Review — two blocking items before merge:
|
|
|
||
| @admin_router.get("/ping", response=AdminPingSchema) | ||
| @platform_admin_required | ||
| def get_admin_ping(request): |
There was a problem hiding this comment.
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.
|
|
||
| def create_org(payload: AdminCreateOrgSchema) -> Org: | ||
| """ | ||
| Create an org and its plan. Takes the AdminCreateOrgSchema the API validated — the |
There was a problem hiding this comment.
can we add only 2 lines of comments here? we dont need this long comment for every functions
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
| is_platform_admin: bool | ||
|
|
||
|
|
||
| class AdminPingSchema(Schema): |
There was a problem hiding this comment.
Removed, along with the /admin/ping endpoint that used it.
| ) | ||
|
|
||
|
|
||
| class AdminCreateOrgSchema(Schema): |
There was a problem hiding this comment.
is this extending current create org schema?
There was a problem hiding this comment.
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.
| return AWSClient.get_instance("ses") | ||
|
|
||
|
|
||
| def _ses_available() -> bool: |
There was a problem hiding this comment.
and why do we need it?
There was a problem hiding this comment.
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.
| return bool(os.getenv("SES_ACCESS_KEY_ID") and os.getenv("SES_SECRET_ACCESS_KEY")) | ||
|
|
||
|
|
||
| def send_text_message(to_email, subject, message): |
There was a problem hiding this comment.
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>
|
Addressed both:
Replied inline on the rest of the comments too. |
- 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>
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
Bug Fixes