"""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")