ppt-tool/backend/api/v1/admin/storage_router.py
Vadym Samoilenko 69a8829750 Phase 3: Bug fixes, feature enhancements, and polish
P0 Critical: presentation isolation (client scoping), storage super_admin fix,
template selection in worker, IMAGE_PROVIDERS list fix.

P1 High: template layout management UI (delete/filter/bulk), slide-based parsing
mode, LLM model listing & connection test, settings persistence to DB (Fernet
encryption), logout button.

P2 Polish: storage improvements (master deck files, per-client breakdown, bulk
delete, hard purge, client selector), image generation error visibility
(__image_error__ badge), hamster wheel loading animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:58:52 +00:00

408 lines
14 KiB
Python

"""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_
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.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."""
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
for p in deleted_presentations:
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 = []
purged_presentations += 1
await session.commit()
audit_service.log(
user_id=admin.id,
action="admin_purge",
resource_type="storage",
details={"presentations": purged_presentations, "files": purged_files, "bytes": purged_bytes},
)
return {
"ok": True,
"purged_presentations": purged_presentations,
"purged_files": purged_files,
"purged_bytes": purged_bytes,
}