ppt-tool/backend/api/v1/admin/master_decks_router.py
Vadym Samoilenko ae41562103 Phase 8: Data-driven slide architecture + template management overhaul
Replaces TSX/Babel compilation pipeline with a JSON element model:
- New _do_parse_v2(): 1 LLM call/layout (vs 2) classifies OXML geometry
  elements into placeholder types → JSON stored in layout_code
- SlideRenderer.tsx: renders JSON element model as %-positioned divs,
  no Babel compilation or runtime errors
- parseLayoutSchema.ts: isJsonLayoutCode() / parseLayoutSchema() /
  mergeElementsWithContent() — full JSON schema parsing layer
- useCustomTemplates.ts: transparent dual-format support (JSON + TSX)
  via parsedLayoutToCompiled() adapter

Template management improvements:
- PresentationLayoutCodeModel: +is_enabled (bool) +thumbnail_path (str)
- Migration 005: adds both columns to presentation_layout_codes
- DELETE /master-decks/{id}: hard delete (files + TemplateModel +
  PresentationLayoutCodeModel rows + MasterDeckModel)
- PATCH /template-management/layouts/{db_id}/toggle-enabled: new endpoint
- LayoutData response: +db_id, +is_enabled, +thumbnail_path
- _register_as_template(): stores thumbnail_path + is_enabled per layout

Admin UI:
- /admin/templates/ — list all custom templates with delete
- /admin/templates/[id]/ — layout grid with screenshots + enable/disable
- AdminSidebar: Templates nav item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 20:05:25 +00:00

456 lines
16 KiB
Python

"""Admin router for master deck upload, parsing, and management."""
import os
import shutil
import uuid
from typing import List, Optional
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
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.environ.get("APP_DATA_DIRECTORY", 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
class LayoutBulkDelete(BaseModel):
indices: List[int]
# --- Helpers ---
async def _list_decks(client_id: uuid.UUID, include_inactive: bool, session: AsyncSession):
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_mode": getattr(d, "parse_mode", None) or "layouts",
"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
]
# --- Endpoints ---
@MASTER_DECKS_ROUTER.get("/master-decks")
async def list_master_decks_flat(
client_id: uuid.UUID = Query(...),
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)
return await _list_decks(client_id, include_inactive, session)
@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)
return await _list_decks(client_id, include_inactive, session)
@MASTER_DECKS_ROUTER.post("/clients/{client_id}/master-decks")
async def upload_master_deck(
client_id: uuid.UUID,
file: UploadFile = File(...),
parse_mode: str = Query("layouts", description="Parse mode: 'layouts' (default, unique slideLayouts) or 'slides' (one layout per slide)"),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_team_admin(admin, client_id, session)
is_pptx_mime = file.content_type in POWERPOINT_TYPES
is_pptx_ext = (file.filename or "").lower().endswith(".pptx")
if not is_pptx_mime and not is_pptx_ext:
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)")
if parse_mode not in ("slides", "layouts"):
parse_mode = "slides"
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_mode=parse_mode,
parse_status="pending",
is_active=True,
)
session.add(deck)
await session.commit()
await session.refresh(deck)
# Kick off async parsing via ARQ (fallback to asyncio.create_task)
try:
from models.sql.job import JobModel
from services.redis_service import enqueue_job
job = JobModel(
user_id=admin.id,
client_id=client_id,
presentation_id=None,
job_type="parse_master_deck",
status="queued",
progress=0,
progress_message="Queued for parsing",
)
session.add(job)
await session.commit()
await enqueue_job("parse_master_deck_task", job_id=str(job.id), deck_id=str(deck_id))
except Exception:
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.delete("/master-decks/{deck_id}/layouts/{layout_index}")
async def delete_layout(
deck_id: uuid.UUID,
layout_index: int,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
"""Delete a single layout from a master deck by index."""
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")
deck.layouts.pop(layout_index)
# Re-index remaining layouts
for i, layout in enumerate(deck.layouts):
layout["index"] = i
from sqlalchemy.orm.attributes import flag_modified
flag_modified(deck, "layouts")
await session.commit()
# Re-register template with remaining layouts
await _re_register_template(deck, session)
return {"ok": True, "remaining_layouts": len(deck.layouts)}
@MASTER_DECKS_ROUTER.post("/master-decks/{deck_id}/layouts/bulk-delete")
async def bulk_delete_layouts(
deck_id: uuid.UUID,
body: LayoutBulkDelete,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
"""Bulk delete layouts from a master deck by indices."""
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:
raise HTTPException(status_code=400, detail="No layouts to delete")
# Validate indices
valid_indices = sorted(set(i for i in body.indices if 0 <= i < len(deck.layouts)), reverse=True)
if not valid_indices:
raise HTTPException(status_code=400, detail="No valid indices provided")
# Remove from highest index first to preserve lower indices
for idx in valid_indices:
deck.layouts.pop(idx)
# Re-index remaining layouts
for i, layout in enumerate(deck.layouts):
layout["index"] = i
from sqlalchemy.orm.attributes import flag_modified
flag_modified(deck, "layouts")
await session.commit()
# Re-register template with remaining layouts
await _re_register_template(deck, session)
return {"ok": True, "deleted": len(valid_indices), "remaining_layouts": len(deck.layouts)}
async def _re_register_template(deck: MasterDeckModel, session: AsyncSession):
"""Re-register a master deck's layouts as a custom template after mutations."""
try:
from services.master_deck_parser_service import _register_as_template
await _register_as_template(deck.id, deck.name, deck.layouts or [], session)
except Exception as e:
print(f"[master_decks] Failed to re-register template: {e}")
@MASTER_DECKS_ROUTER.post("/master-decks/{deck_id}/reparse")
async def reparse_master_deck(
deck_id: uuid.UUID,
parse_mode: Optional[str] = Query(None, description="Parse mode: 'slides' or 'layouts'. Defaults to deck's current mode."),
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")
# Update parse mode if provided
if parse_mode and parse_mode in ("slides", "layouts"):
deck.parse_mode = parse_mode
deck.parse_status = "pending"
await session.commit()
try:
from models.sql.job import JobModel
from services.redis_service import enqueue_job
job = JobModel(
user_id=admin.id,
client_id=deck.client_id,
presentation_id=None,
job_type="parse_master_deck",
status="queued",
progress=0,
progress_message="Queued for re-parsing",
)
session.add(job)
await session.commit()
await enqueue_job("parse_master_deck_task", job_id=str(job.id), deck_id=str(deck_id))
except Exception as e:
print(f"[reparse] Failed to enqueue job, falling back to async: {e}")
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)
# Hard delete: remove physical files + all DB records
deck_dir = _deck_dir(deck.client_id, deck_id)
if os.path.isdir(deck_dir):
shutil.rmtree(deck_dir, ignore_errors=True)
# Delete PresentationLayoutCodeModel rows
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sql.template import TemplateModel
from sqlalchemy import delete as sql_delete
await session.execute(
sql_delete(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == deck_id
)
)
# Delete TemplateModel (deck_id == template id)
await session.execute(
sql_delete(TemplateModel).where(TemplateModel.id == deck_id)
)
# Delete MasterDeckModel
await session.delete(deck)
await session.commit()
return {"ok": True}
@MASTER_DECKS_ROUTER.get("/master-decks/{deck_id}/screenshot/{filename}")
async def get_master_deck_screenshot(
deck_id: uuid.UUID,
filename: str,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
"""Serve a master deck screenshot image with auth check."""
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)
# Sanitize filename to prevent path traversal
safe_name = os.path.basename(filename)
screenshot_dir = os.path.join(
DATA_DIR, "clients", str(deck.client_id),
"master_decks", str(deck_id), "screenshots"
)
file_path = os.path.join(screenshot_dir, safe_name)
if not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="Screenshot not found")
return FileResponse(file_path, media_type="image/png")