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>
456 lines
16 KiB
Python
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")
|