- 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>
191 lines
6.4 KiB
Python
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}
|