"""Admin router for storage management — list, download, delete presentations.""" import os import uuid from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import FileResponse from pydantic import BaseModel from sqlalchemy import func, select, and_, delete from sqlalchemy.ext.asyncio import AsyncSession from models.sql.client import ClientModel from models.sql.master_deck import MasterDeckModel from models.sql.presentation import PresentationModel from models.sql.slide import SlideModel from models.sql.user import UserModel from services import audit_service from services.access_service import get_accessible_client_ids from services.database import get_async_session from utils.auth_dependencies import require_client_admin from utils.datetime_utils import get_current_utc_datetime STORAGE_ROUTER = APIRouter(tags=["Admin - Storage"]) async def _resolve_client_filter(client_id, user, session): """Return SQLAlchemy filter or None (super_admin sees all).""" if client_id: return PresentationModel.client_id == client_id if user.role == "super_admin": return None cids = await get_accessible_client_ids(user, session) if not cids: return PresentationModel.client_id == None # noqa: E711 if len(cids) == 1: return PresentationModel.client_id == cids[0] return PresentationModel.client_id.in_(cids) @STORAGE_ROUTER.get("/storage/summary") async def storage_summary( client_id: Optional[uuid.UUID] = Query(None), admin: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """Storage summary: total presentations, files, and disk usage.""" cf = await _resolve_client_filter(client_id, admin, session) filters = [PresentationModel.deleted_at.is_(None)] if cf is not None: filters.append(cf) # Count presentations count_q = select(func.count()).where(and_(*filters)) total_presentations = (await session.execute(count_q)).scalar() or 0 # Get all file_paths to compute size path_filters = [ PresentationModel.deleted_at.is_(None), PresentationModel.file_paths.isnot(None), ] if cf is not None: path_filters.append(cf) paths_q = select(PresentationModel.file_paths).where(and_(*path_filters)) result = await session.execute(paths_q) all_paths = result.scalars().all() total_files = 0 total_size_bytes = 0 for file_paths in all_paths: if not file_paths: continue for path in file_paths: if path and os.path.isfile(path): total_files += 1 total_size_bytes += os.path.getsize(path) # Count soft-deleted presentations deleted_filters = [PresentationModel.deleted_at.isnot(None)] if cf is not None: deleted_filters.append(cf) deleted_count_q = select(func.count()).select_from(PresentationModel).where(and_(*deleted_filters)) total_deleted = (await session.execute(deleted_count_q)).scalar() or 0 # Scan master decks deck_filters = [] if cf is not None: # Reuse client filter but on MasterDeckModel if client_id: deck_filters.append(MasterDeckModel.client_id == client_id) elif admin.role != "super_admin": cids = await get_accessible_client_ids(admin, session) if cids: deck_filters.append(MasterDeckModel.client_id.in_(cids)) deck_count_q = select(func.count()).select_from(MasterDeckModel) if deck_filters: deck_count_q = deck_count_q.where(and_(*deck_filters)) total_master_decks = (await session.execute(deck_count_q)).scalar() or 0 deck_q = select(MasterDeckModel) if deck_filters: deck_q = deck_q.where(and_(*deck_filters)) deck_result = await session.execute(deck_q) decks = deck_result.scalars().all() master_deck_files = 0 master_deck_size = 0 for deck in decks: if deck.original_file_path and os.path.isfile(deck.original_file_path): master_deck_files += 1 master_deck_size += os.path.getsize(deck.original_file_path) if deck.thumbnail_path and os.path.isfile(deck.thumbnail_path): master_deck_files += 1 master_deck_size += os.path.getsize(deck.thumbnail_path) if deck.layouts: for layout in deck.layouts: sp = layout.get("screenshot_path") if sp and os.path.isfile(sp): master_deck_files += 1 master_deck_size += os.path.getsize(sp) return { "total_presentations": total_presentations, "total_files": total_files, "total_size_bytes": total_size_bytes, "total_deleted": total_deleted, "total_master_decks": total_master_decks, "master_deck_files": master_deck_files, "master_deck_size_bytes": master_deck_size, } @STORAGE_ROUTER.get("/storage/presentations") async def list_storage_presentations( client_id: Optional[uuid.UUID] = Query(None), admin: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """List presentations with file metadata for storage management.""" cf = await _resolve_client_filter(client_id, admin, session) filters = [PresentationModel.deleted_at.is_(None)] if cf is not None: filters.append(cf) stmt = ( select(PresentationModel) .where(and_(*filters)) .order_by(PresentationModel.created_at.desc()) ) result = await session.execute(stmt) presentations = result.scalars().all() items = [] for p in presentations: file_count = 0 total_size = 0 if p.file_paths: for path in p.file_paths: if path and os.path.isfile(path): file_count += 1 total_size += os.path.getsize(path) items.append({ "id": str(p.id), "title": p.title, "status": p.status, "created_at": p.created_at.isoformat() if p.created_at else None, "file_count": file_count, "total_size_bytes": total_size, "has_export": bool(p.file_paths and any( fp.endswith(".pptx") for fp in p.file_paths if fp )), }) return items @STORAGE_ROUTER.get("/storage/presentations/{presentation_id}/download") async def download_presentation( presentation_id: uuid.UUID, admin: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """Download the PPTX export file for a presentation.""" presentation = await session.get(PresentationModel, presentation_id) if not presentation: raise HTTPException(status_code=404, detail="Presentation not found") # Verify access if presentation.client_id: cids = await get_accessible_client_ids(admin, session) if presentation.client_id not in cids: raise HTTPException(status_code=403, detail="Access denied") if not presentation.file_paths: raise HTTPException(status_code=404, detail="No export files available") # Find the PPTX file pptx_path = next( (p for p in presentation.file_paths if p and p.endswith(".pptx") and os.path.isfile(p)), None, ) if not pptx_path: raise HTTPException(status_code=404, detail="PPTX file not found on disk") filename = f"{presentation.title or 'presentation'}.pptx" return FileResponse( pptx_path, filename=filename, media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation", ) @STORAGE_ROUTER.delete("/storage/presentations/{presentation_id}") async def delete_presentation_storage( presentation_id: uuid.UUID, admin: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """Soft-delete a presentation (files cleaned up by retention service).""" presentation = await session.get(PresentationModel, presentation_id) if not presentation: raise HTTPException(status_code=404, detail="Presentation not found") # Verify access if presentation.client_id: cids = await get_accessible_client_ids(admin, session) if presentation.client_id not in cids: raise HTTPException(status_code=403, detail="Access denied") presentation.deleted_at = get_current_utc_datetime() await session.commit() audit_service.log( user_id=admin.id, action="admin_delete", resource_type="presentation", resource_id=presentation.id, client_id=presentation.client_id, ) return {"ok": True} class BulkDeleteRequest(BaseModel): ids: List[uuid.UUID] @STORAGE_ROUTER.get("/storage/breakdown") async def storage_breakdown( admin: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """Per-client storage breakdown.""" # Only super_admin can see breakdown of all clients if admin.role != "super_admin": raise HTTPException(status_code=403, detail="Super admin only") # Get all clients clients_result = await session.execute(select(ClientModel)) clients = clients_result.scalars().all() breakdown = [] for client in clients: cf = PresentationModel.client_id == client.id # Count presentations count_q = select(func.count()).select_from(PresentationModel).where( and_(PresentationModel.deleted_at.is_(None), cf) ) pres_count = (await session.execute(count_q)).scalar() or 0 # Presentation file sizes paths_q = select(PresentationModel.file_paths).where( and_(PresentationModel.deleted_at.is_(None), PresentationModel.file_paths.isnot(None), cf) ) result = await session.execute(paths_q) all_paths = result.scalars().all() pres_files = 0 pres_size = 0 for file_paths in all_paths: if not file_paths: continue for path in file_paths: if path and os.path.isfile(path): pres_files += 1 pres_size += os.path.getsize(path) # Master deck count & size deck_q = select(MasterDeckModel).where(MasterDeckModel.client_id == client.id) deck_result = await session.execute(deck_q) decks = deck_result.scalars().all() deck_files = 0 deck_size = 0 for deck in decks: if deck.original_file_path and os.path.isfile(deck.original_file_path): deck_files += 1 deck_size += os.path.getsize(deck.original_file_path) if deck.thumbnail_path and os.path.isfile(deck.thumbnail_path): deck_files += 1 deck_size += os.path.getsize(deck.thumbnail_path) if deck.layouts: for layout in deck.layouts: sp = layout.get("screenshot_path") if sp and os.path.isfile(sp): deck_files += 1 deck_size += os.path.getsize(sp) breakdown.append({ "client_id": str(client.id), "client_name": client.name, "presentations": pres_count, "presentation_files": pres_files, "presentation_size_bytes": pres_size, "master_decks": len(decks), "master_deck_files": deck_files, "master_deck_size_bytes": deck_size, "total_size_bytes": pres_size + deck_size, }) breakdown.sort(key=lambda x: x["total_size_bytes"], reverse=True) return breakdown @STORAGE_ROUTER.post("/storage/presentations/bulk-delete") async def bulk_delete_presentations( body: BulkDeleteRequest, admin: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """Soft-delete multiple presentations at once.""" deleted_count = 0 for pid in body.ids: presentation = await session.get(PresentationModel, pid) if not presentation or presentation.deleted_at: continue # Access check if presentation.client_id: cids = await get_accessible_client_ids(admin, session) if presentation.client_id not in cids: continue presentation.deleted_at = get_current_utc_datetime() deleted_count += 1 await session.commit() audit_service.log( user_id=admin.id, action="admin_bulk_delete", resource_type="presentation", details={"count": deleted_count, "ids": [str(i) for i in body.ids]}, ) return {"ok": True, "deleted_count": deleted_count} @STORAGE_ROUTER.post("/storage/purge") async def purge_deleted_storage( client_id: Optional[uuid.UUID] = Query(None), admin: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): """Hard-delete files for soft-deleted presentations (exports + generated images).""" if admin.role != "super_admin": raise HTTPException(status_code=403, detail="Super admin only") filters = [PresentationModel.deleted_at.isnot(None)] if client_id: filters.append(PresentationModel.client_id == client_id) stmt = select(PresentationModel).where(and_(*filters)) result = await session.execute(stmt) deleted_presentations = result.scalars().all() purged_files = 0 purged_bytes = 0 purged_presentations = 0 purged_images = 0 print(f"[PURGE] Found {len(deleted_presentations)} soft-deleted presentations") for p in deleted_presentations: # Delete export files (PDF/PPTX) if p.file_paths: for path in p.file_paths: if path and os.path.isfile(path): try: size = os.path.getsize(path) os.remove(path) purged_files += 1 purged_bytes += size except OSError: pass p.file_paths = [] # Delete generated images from slides slides_stmt = select(SlideModel).where(SlideModel.presentation == p.id) slides_result = await session.execute(slides_stmt) slides = slides_result.scalars().all() print(f"[PURGE] Presentation {p.id}: {len(slides)} slides") for slide in slides: if slide.content and isinstance(slide.content, dict): # Extract image path from content.image.__image_url__ image_data = slide.content.get("image") if image_data and isinstance(image_data, dict): image_url = image_data.get("__image_url__") print(f"[PURGE] Slide has image URL: {image_url}") if image_url and image_url.startswith("/app_data/images/"): # URL is already an absolute path inside container image_path = image_url print(f"[PURGE] Checking path: {image_path}, exists: {os.path.isfile(image_path)}") if os.path.isfile(image_path): try: size = os.path.getsize(image_path) os.remove(image_path) purged_images += 1 purged_bytes += size print(f"[PURGE] ✓ Deleted: {image_path} ({size} bytes)") except OSError as e: print(f"[PURGE] ✗ Error deleting {image_path}: {e}") purged_presentations += 1 print(f"[PURGE] TOTAL: {purged_presentations} presentations, {purged_files} files, {purged_images} images, {purged_bytes} bytes") # Hard-delete presentation records and their slides from DB print(f"[PURGE] Hard-deleting {len(deleted_presentations)} presentation records from DB...") for p in deleted_presentations: # Slides will be cascade-deleted due to FK ondelete="CASCADE" await session.delete(p) print(f"[PURGE] ✓ Deleted {len(deleted_presentations)} presentations from database") await session.commit() audit_service.log( user_id=admin.id, action="admin_purge", resource_type="storage", details={ "presentations": purged_presentations, "files": purged_files, "images": purged_images, "bytes": purged_bytes }, ) return { "ok": True, "purged_presentations": purged_presentations, "purged_files": purged_files, "purged_images": purged_images, "purged_bytes": purged_bytes, }