From c3654dc0690bd22d8238710350b3402293da0901 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 19:44:10 +0800 Subject: [PATCH] Fix audit log target for deleted users + create user 500 error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Audit log: COALESCE target_username with detail JSON fallback so deleted users still show their username in the target column (target_user_id is SET NULL by FK cascade, but detail JSON preserves the username). 2. Create/get user: add exclude={"active_sessions"} to model_dump() calls — UserListItem defaults active_sessions=0, so model_dump() includes it, then the explicit active_sessions=N keyword argument causes a duplicate keyword TypeError. DB commit already happened, so the user exists but the response was a 500. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/admin.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 963fe4a..c4acdb6 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -16,6 +16,7 @@ from typing import Optional import sqlalchemy as sa from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -58,6 +59,22 @@ router = APIRouter( ) +# --------------------------------------------------------------------------- +# Audit log helper — resolve target username even for deleted users +# --------------------------------------------------------------------------- + +def _target_username_col(target_alias, audit_model): + """ + COALESCE: prefer the live username from the users table, + fall back to the username stored in the audit detail JSON + (survives user deletion since audit_log.target_user_id → SET NULL). + """ + return sa.func.coalesce( + target_alias.username, + sa.cast(audit_model.detail, JSONB)["username"].as_string(), + ).label("target_username") + + # --------------------------------------------------------------------------- # Session revocation helper (used in multiple endpoints) # --------------------------------------------------------------------------- @@ -152,7 +169,7 @@ async def get_user( active_sessions = session_result.scalar_one() return UserDetailResponse( - **UserListItem.model_validate(user).model_dump(), + **UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}), active_sessions=active_sessions, ) @@ -197,7 +214,7 @@ async def create_user( await db.commit() return UserDetailResponse( - **UserListItem.model_validate(new_user).model_dump(), + **UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}), active_sessions=0, ) @@ -668,7 +685,7 @@ async def admin_dashboard( sa.select( AuditLog, actor_user.username.label("actor_username"), - target_user.username.label("target_username"), + _target_username_col(target_user, AuditLog), ) .outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id) .outerjoin(target_user, AuditLog.target_user_id == target_user.id) @@ -722,7 +739,7 @@ async def get_audit_log( sa.select( AuditLog, actor_user.username.label("actor_username"), - target_user.username.label("target_username"), + _target_username_col(target_user, AuditLog), ) .outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id) .outerjoin(target_user, AuditLog.target_user_id == target_user.id)