ppt-tool/backend/api/v1/admin/storage_router.py
Vadym Samoilenko d3d1667a79 Phase 2: Admin panel, analytics, storage, template pipeline, multi-provider LLM
- Fix admin sidebar: remove duplicate Teams, add Storage nav item
- Analytics: client-scoped queries, super_admin sees all (including NULL client_id)
- Storage management: list/download/delete presentations with file metadata
- Settings page with brand config router
- AI usage tracking: new AIUsageModel, ai_usage_service, analytics endpoint
- Master deck → template bridge: _register_as_template creates TemplateModel
  + PresentationLayoutCodeModel so parsed layouts appear in template picker
- Multi-provider LLM vision in parser: Anthropic/Google/OpenAI with asyncio.to_thread
- Fix PPTX upload 400: accept by .pptx extension (browser sends octet-stream)
- Fix reparse FK violation: presentation_id=None for parse_master_deck jobs
- Worker job_timeout increased to 1800s for LLM-heavy master deck parsing
- PYTHONUNBUFFERED=1 in docker-compose worker for real-time log output
- Auth: clientId in /me response, dev-login cookie improvements
- Frontend: auth slice clientId, master-deck thumbnails, storage page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:39:34 +00:00

191 lines
6.4 KiB
Python

"""Admin router for storage management — list, download, delete presentations."""
import os
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy import func, select, and_
from sqlalchemy.ext.asyncio import AsyncSession
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)
return {
"total_presentations": total_presentations,
"total_files": total_files,
"total_size_bytes": total_size_bytes,
}
@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}