ppt-tool/backend/api/v1/admin/users_router.py
Vadym Samoilenko e8295d6e71 Phase 4: Fix critical bugs, improve document parsing, add vision OCR
- 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>
2026-02-27 14:07:00 +00:00

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