- Fix SSE stream 500: use async_session_maker inside StreamingResponse generator (Depends session closes when endpoint returns, before streaming starts) - Fix template application: store template_name in prepare endpoint so worker uses the selected custom template instead of defaulting to "general" - Fix OverlayLoader: replace loading.gif with HamsterLoader component - Fix parse_mode default: change from "slides" to "layouts" to avoid 70+ layouts - Update Gemini Flash model to gemini-3.1-flash-image-preview - Improve DOCX parsing: python-docx for structured table extraction, OCR enabled - Add vision-based image text extraction via Gemini for uploaded images - Add LayoutParser integration for slide layout structure analysis - Add Phase 4 MVP features: transfer ownership, URL input, follow-up questions, attachment-to-slide mapping, content router Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
188 lines
6 KiB
Python
188 lines
6 KiB
Python
"""Admin router for user management. Super Admin only."""
|
|
from typing import List, Optional
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlmodel import select, update
|
|
|
|
from models.sql.presentation import PresentationModel
|
|
from models.sql.user import UserModel
|
|
from services.database import get_async_session
|
|
from utils.auth_dependencies import require_super_admin
|
|
|
|
USERS_ROUTER = APIRouter(prefix="/users", tags=["Admin - Users"])
|
|
|
|
VALID_ROLES = {"super_admin", "client_admin", "user"}
|
|
|
|
|
|
class TransferOwnershipRequest(BaseModel):
|
|
new_owner_id: uuid.UUID
|
|
client_id: Optional[uuid.UUID] = None
|
|
|
|
|
|
@USERS_ROUTER.get("", response_model=List[dict])
|
|
async def list_users(
|
|
_: UserModel = Depends(require_super_admin),
|
|
is_active: Optional[bool] = Query(None),
|
|
role: Optional[str] = Query(None),
|
|
offset: int = Query(0, ge=0),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
query = select(UserModel)
|
|
if is_active is not None:
|
|
query = query.where(UserModel.is_active == is_active)
|
|
if role:
|
|
query = query.where(UserModel.role == role)
|
|
query = query.order_by(UserModel.created_at.desc()).offset(offset).limit(limit)
|
|
|
|
result = await session.execute(query)
|
|
users = result.scalars().all()
|
|
return [
|
|
{
|
|
"id": str(u.id),
|
|
"email": u.email,
|
|
"display_name": u.display_name,
|
|
"role": u.role,
|
|
"is_active": u.is_active,
|
|
"last_login_at": u.last_login_at.isoformat() if u.last_login_at else None,
|
|
"created_at": u.created_at.isoformat() if u.created_at else None,
|
|
}
|
|
for u in users
|
|
]
|
|
|
|
|
|
@USERS_ROUTER.get("/{user_id}", response_model=dict)
|
|
async def get_user(
|
|
user_id: uuid.UUID,
|
|
_: UserModel = Depends(require_super_admin),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
user = await session.get(UserModel, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
return {
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"display_name": user.display_name,
|
|
"role": user.role,
|
|
"is_active": user.is_active,
|
|
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
|
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
|
}
|
|
|
|
|
|
@USERS_ROUTER.put("/{user_id}/role")
|
|
async def update_user_role(
|
|
user_id: uuid.UUID,
|
|
role: str = Query(...),
|
|
admin: UserModel = Depends(require_super_admin),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
if role not in VALID_ROLES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid role. Must be one of: {', '.join(VALID_ROLES)}",
|
|
)
|
|
|
|
user = await session.get(UserModel, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
if user.id == admin.id:
|
|
raise HTTPException(status_code=400, detail="Cannot change your own role")
|
|
|
|
user.role = role
|
|
session.add(user)
|
|
await session.commit()
|
|
return {"message": "Role updated", "user_id": str(user.id), "role": role}
|
|
|
|
|
|
@USERS_ROUTER.post("/{user_id}/transfer-ownership")
|
|
async def transfer_ownership(
|
|
user_id: uuid.UUID,
|
|
body: TransferOwnershipRequest,
|
|
_: UserModel = Depends(require_super_admin),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Transfer all presentations from one user to another.
|
|
|
|
Used for GDPR compliance before deactivating a user.
|
|
"""
|
|
if user_id == body.new_owner_id:
|
|
raise HTTPException(
|
|
status_code=400, detail="Cannot transfer ownership to the same user"
|
|
)
|
|
|
|
# Validate source user exists
|
|
source_user = await session.get(UserModel, user_id)
|
|
if not source_user:
|
|
raise HTTPException(status_code=404, detail="Source user not found")
|
|
|
|
# Validate target user exists
|
|
target_user = await session.get(UserModel, body.new_owner_id)
|
|
if not target_user:
|
|
raise HTTPException(status_code=404, detail="Target user not found")
|
|
|
|
# Build the update statement for non-deleted presentations owned by the source user
|
|
stmt = (
|
|
update(PresentationModel)
|
|
.where(PresentationModel.owner_id == user_id)
|
|
.where(PresentationModel.deleted_at.is_(None))
|
|
)
|
|
|
|
if body.client_id is not None:
|
|
stmt = stmt.where(PresentationModel.client_id == body.client_id)
|
|
|
|
stmt = stmt.values(owner_id=body.new_owner_id)
|
|
result = await session.execute(stmt)
|
|
await session.commit()
|
|
|
|
transferred_count = result.rowcount
|
|
|
|
return {
|
|
"message": f"Transferred {transferred_count} presentations",
|
|
"from_user_id": str(user_id),
|
|
"to_user_id": str(body.new_owner_id),
|
|
}
|
|
|
|
|
|
@USERS_ROUTER.delete("/{user_id}")
|
|
async def deactivate_user(
|
|
user_id: uuid.UUID,
|
|
admin: UserModel = Depends(require_super_admin),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
user = await session.get(UserModel, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
if user.id == admin.id:
|
|
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
|
|
|
|
# Check how many active presentations this user still owns
|
|
count_query = (
|
|
select(func.count())
|
|
.select_from(PresentationModel)
|
|
.where(PresentationModel.owner_id == user_id)
|
|
.where(PresentationModel.deleted_at.is_(None))
|
|
)
|
|
count_result = await session.execute(count_query)
|
|
presentation_count = count_result.scalar_one()
|
|
|
|
user.is_active = False
|
|
session.add(user)
|
|
await session.commit()
|
|
|
|
response = {"message": "User deactivated", "user_id": str(user.id)}
|
|
|
|
if presentation_count > 0:
|
|
response["warning"] = (
|
|
f"User still has {presentation_count} active presentations. "
|
|
"Consider transferring ownership first."
|
|
)
|
|
|
|
return response
|