ppt-tool/backend/api/v1/admin/master_decks_router.py
Vadym Samoilenko cf21ba4516 Phase 1-2: Foundation + Admin Panel & Client Management
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>
2026-02-26 15:37:17 +00:00

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}