Phase 1 (Foundation): - Project restructure (presenton-main → backend/ + frontend/) - Database schema (8 new models, Alembic config, seed script) - Auth (Azure AD SSO + dev bypass, JWT sessions, AuthMiddleware) - RBAC (access_service, rbac_middleware, admin routers) - Audit logging (fire-and-forget, AuditMiddleware, admin router) - i18n (react-i18next with 5 namespace files) Phase 2 (Admin Panel & Client Management): - Admin panel shell (sidebar layout, role guard, 12 pages) - Redux admin slice with 18 async thunks - User management (role changes, deactivation) - Client management (CRUD, brand config, team management) - Brand config editor (colors, fonts, logos, voice rules) - Master deck upload & parser (PPTX → HTML → React pipeline) - Audit log viewer with filters and CSV/JSON export Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
262 lines
8.2 KiB
Python
262 lines
8.2 KiB
Python
"""Admin router for master deck upload, parsing, and management."""
|
|
import os
|
|
import shutil
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from constants.documents import POWERPOINT_TYPES
|
|
from models.sql.master_deck import MasterDeckModel
|
|
from models.sql.user import UserModel
|
|
from services.database import get_async_session
|
|
from api.middlewares.rbac_middleware import check_team_admin
|
|
from utils.auth_dependencies import require_client_admin
|
|
|
|
MASTER_DECKS_ROUTER = APIRouter(tags=["Admin - Master Decks"])
|
|
|
|
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "data")
|
|
|
|
|
|
def _deck_dir(client_id: uuid.UUID, deck_id: uuid.UUID) -> str:
|
|
return os.path.join(DATA_DIR, "clients", str(client_id), "master_decks", str(deck_id))
|
|
|
|
|
|
# --- Request / Response schemas ---
|
|
|
|
|
|
class MasterDeckUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
class LayoutUpdate(BaseModel):
|
|
layout_name: Optional[str] = None
|
|
layout_type: Optional[str] = None
|
|
react_code: Optional[str] = None
|
|
|
|
|
|
# --- Endpoints ---
|
|
|
|
|
|
@MASTER_DECKS_ROUTER.get("/clients/{client_id}/master-decks")
|
|
async def list_master_decks(
|
|
client_id: uuid.UUID,
|
|
include_inactive: bool = Query(False),
|
|
admin: UserModel = Depends(require_client_admin),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
await check_team_admin(admin, client_id, session)
|
|
|
|
stmt = select(MasterDeckModel).where(MasterDeckModel.client_id == client_id)
|
|
if not include_inactive:
|
|
stmt = stmt.where(MasterDeckModel.is_active == True)
|
|
stmt = stmt.order_by(MasterDeckModel.created_at.desc())
|
|
|
|
result = await session.execute(stmt)
|
|
decks = result.scalars().all()
|
|
|
|
return [
|
|
{
|
|
"id": str(d.id),
|
|
"client_id": str(d.client_id),
|
|
"name": d.name,
|
|
"description": d.description,
|
|
"thumbnail_path": d.thumbnail_path,
|
|
"parse_status": d.parse_status,
|
|
"is_active": d.is_active,
|
|
"layouts": d.layouts,
|
|
"created_at": d.created_at.isoformat() if d.created_at else None,
|
|
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
|
}
|
|
for d in decks
|
|
]
|
|
|
|
|
|
@MASTER_DECKS_ROUTER.post("/clients/{client_id}/master-decks")
|
|
async def upload_master_deck(
|
|
client_id: uuid.UUID,
|
|
file: UploadFile = File(...),
|
|
admin: UserModel = Depends(require_client_admin),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
await check_team_admin(admin, client_id, session)
|
|
|
|
if file.content_type not in POWERPOINT_TYPES:
|
|
raise HTTPException(status_code=400, detail="Only PPTX files are accepted")
|
|
|
|
if hasattr(file, "size") and file.size and file.size > 100 * 1024 * 1024:
|
|
raise HTTPException(status_code=400, detail="File too large (max 100 MB)")
|
|
|
|
deck_id = uuid.uuid4()
|
|
deck_path = _deck_dir(client_id, deck_id)
|
|
os.makedirs(deck_path, exist_ok=True)
|
|
|
|
original_name = file.filename or "presentation.pptx"
|
|
file_path = os.path.join(deck_path, original_name)
|
|
content = await file.read()
|
|
with open(file_path, "wb") as f:
|
|
f.write(content)
|
|
|
|
deck = MasterDeckModel(
|
|
id=deck_id,
|
|
client_id=client_id,
|
|
name=os.path.splitext(original_name)[0],
|
|
original_file_path=file_path,
|
|
parse_status="pending",
|
|
is_active=True,
|
|
)
|
|
session.add(deck)
|
|
await session.commit()
|
|
await session.refresh(deck)
|
|
|
|
# Kick off async parsing
|
|
import asyncio
|
|
from services.master_deck_parser_service import parse_master_deck
|
|
|
|
asyncio.create_task(parse_master_deck(deck_id))
|
|
|
|
return {
|
|
"id": str(deck.id),
|
|
"client_id": str(deck.client_id),
|
|
"name": deck.name,
|
|
"parse_status": deck.parse_status,
|
|
"created_at": deck.created_at.isoformat() if deck.created_at else None,
|
|
}
|
|
|
|
|
|
@MASTER_DECKS_ROUTER.get("/master-decks/{deck_id}")
|
|
async def get_master_deck(
|
|
deck_id: uuid.UUID,
|
|
admin: UserModel = Depends(require_client_admin),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
deck = await session.get(MasterDeckModel, deck_id)
|
|
if not deck:
|
|
raise HTTPException(status_code=404, detail="Master deck not found")
|
|
|
|
await check_team_admin(admin, deck.client_id, session)
|
|
|
|
return {
|
|
"id": str(deck.id),
|
|
"client_id": str(deck.client_id),
|
|
"name": deck.name,
|
|
"description": deck.description,
|
|
"original_file_path": deck.original_file_path,
|
|
"thumbnail_path": deck.thumbnail_path,
|
|
"parsed_config": deck.parsed_config,
|
|
"layouts": deck.layouts,
|
|
"parse_status": deck.parse_status,
|
|
"is_active": deck.is_active,
|
|
"created_at": deck.created_at.isoformat() if deck.created_at else None,
|
|
"updated_at": deck.updated_at.isoformat() if deck.updated_at else None,
|
|
}
|
|
|
|
|
|
@MASTER_DECKS_ROUTER.put("/master-decks/{deck_id}")
|
|
async def update_master_deck(
|
|
deck_id: uuid.UUID,
|
|
body: MasterDeckUpdate,
|
|
admin: UserModel = Depends(require_client_admin),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
deck = await session.get(MasterDeckModel, deck_id)
|
|
if not deck:
|
|
raise HTTPException(status_code=404, detail="Master deck not found")
|
|
|
|
await check_team_admin(admin, deck.client_id, session)
|
|
|
|
if body.name is not None:
|
|
deck.name = body.name
|
|
if body.description is not None:
|
|
deck.description = body.description
|
|
if body.is_active is not None:
|
|
deck.is_active = body.is_active
|
|
|
|
await session.commit()
|
|
await session.refresh(deck)
|
|
|
|
return {"ok": True, "id": str(deck.id)}
|
|
|
|
|
|
@MASTER_DECKS_ROUTER.put("/master-decks/{deck_id}/layouts/{layout_index}")
|
|
async def update_layout(
|
|
deck_id: uuid.UUID,
|
|
layout_index: int,
|
|
body: LayoutUpdate,
|
|
admin: UserModel = Depends(require_client_admin),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
deck = await session.get(MasterDeckModel, deck_id)
|
|
if not deck:
|
|
raise HTTPException(status_code=404, detail="Master deck not found")
|
|
|
|
await check_team_admin(admin, deck.client_id, session)
|
|
|
|
if not deck.layouts or layout_index < 0 or layout_index >= len(deck.layouts):
|
|
raise HTTPException(status_code=404, detail="Layout not found at index")
|
|
|
|
layout = deck.layouts[layout_index]
|
|
if body.layout_name is not None:
|
|
layout["layout_name"] = body.layout_name
|
|
if body.layout_type is not None:
|
|
layout["layout_type"] = body.layout_type
|
|
if body.react_code is not None:
|
|
layout["react_code"] = body.react_code
|
|
|
|
# SQLAlchemy needs to detect mutation on JSON column
|
|
from sqlalchemy.orm.attributes import flag_modified
|
|
|
|
flag_modified(deck, "layouts")
|
|
await session.commit()
|
|
|
|
return {"ok": True, "layout_index": layout_index}
|
|
|
|
|
|
@MASTER_DECKS_ROUTER.post("/master-decks/{deck_id}/reparse")
|
|
async def reparse_master_deck(
|
|
deck_id: uuid.UUID,
|
|
admin: UserModel = Depends(require_client_admin),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
deck = await session.get(MasterDeckModel, deck_id)
|
|
if not deck:
|
|
raise HTTPException(status_code=404, detail="Master deck not found")
|
|
|
|
await check_team_admin(admin, deck.client_id, session)
|
|
|
|
if deck.parse_status == "processing":
|
|
raise HTTPException(status_code=409, detail="Deck is already being parsed")
|
|
|
|
deck.parse_status = "pending"
|
|
await session.commit()
|
|
|
|
import asyncio
|
|
from services.master_deck_parser_service import parse_master_deck
|
|
|
|
asyncio.create_task(parse_master_deck(deck_id))
|
|
|
|
return {"ok": True, "parse_status": "pending"}
|
|
|
|
|
|
@MASTER_DECKS_ROUTER.delete("/master-decks/{deck_id}")
|
|
async def delete_master_deck(
|
|
deck_id: uuid.UUID,
|
|
admin: UserModel = Depends(require_client_admin),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
deck = await session.get(MasterDeckModel, deck_id)
|
|
if not deck:
|
|
raise HTTPException(status_code=404, detail="Master deck not found")
|
|
|
|
await check_team_admin(admin, deck.client_id, session)
|
|
|
|
# Soft delete
|
|
deck.is_active = False
|
|
await session.commit()
|
|
|
|
return {"ok": True}
|