ppt-tool/backend/api/v1/admin/brand_config_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

209 lines
6.6 KiB
Python

"""Admin router for brand configuration management."""
import os
import uuid
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from models.sql.brand_config import BrandConfigModel
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
BRAND_CONFIG_ROUTER = APIRouter(tags=["Admin - Brand Config"])
DATA_DIR = os.environ.get("DATA_DIR", "data")
def _ensure_dir(path: str) -> None:
os.makedirs(path, exist_ok=True)
@BRAND_CONFIG_ROUTER.get("/clients/{client_id}/brand")
async def get_brand_config(
client_id: uuid.UUID,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_team_admin(admin, client_id, session)
result = await session.execute(
select(BrandConfigModel).where(BrandConfigModel.client_id == client_id)
)
config = result.scalar_one_or_none()
if not config:
return {
"id": None,
"client_id": str(client_id),
"primary_colors": None,
"secondary_colors": None,
"fonts": None,
"logo_paths": None,
"voice_rules": None,
"voice_examples": None,
"guideline_doc_path": None,
}
return {
"id": str(config.id),
"client_id": str(config.client_id),
"primary_colors": config.primary_colors,
"secondary_colors": config.secondary_colors,
"fonts": config.fonts,
"logo_paths": config.logo_paths,
"voice_rules": config.voice_rules,
"voice_examples": config.voice_examples,
"guideline_doc_path": config.guideline_doc_path,
}
@BRAND_CONFIG_ROUTER.put("/clients/{client_id}/brand")
async def update_brand_config(
client_id: uuid.UUID,
primary_colors: Optional[list] = Body(None, embed=True),
secondary_colors: Optional[list] = Body(None, embed=True),
fonts: Optional[dict] = Body(None, embed=True),
voice_rules: Optional[str] = Body(None, embed=True),
voice_examples: Optional[list] = Body(None, embed=True),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_team_admin(admin, client_id, session)
result = await session.execute(
select(BrandConfigModel).where(BrandConfigModel.client_id == client_id)
)
config = result.scalar_one_or_none()
if not config:
config = BrandConfigModel(client_id=client_id)
if primary_colors is not None:
config.primary_colors = primary_colors
if secondary_colors is not None:
config.secondary_colors = secondary_colors
if fonts is not None:
config.fonts = fonts
if voice_rules is not None:
config.voice_rules = voice_rules
if voice_examples is not None:
config.voice_examples = voice_examples
session.add(config)
await session.commit()
return {
"id": str(config.id),
"client_id": str(config.client_id),
"primary_colors": config.primary_colors,
"secondary_colors": config.secondary_colors,
"fonts": config.fonts,
"logo_paths": config.logo_paths,
"voice_rules": config.voice_rules,
"voice_examples": config.voice_examples,
"guideline_doc_path": config.guideline_doc_path,
}
@BRAND_CONFIG_ROUTER.post("/clients/{client_id}/brand/logo")
async def upload_logo(
client_id: uuid.UUID,
file: UploadFile = File(...),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_team_admin(admin, client_id, session)
logo_dir = os.path.join(DATA_DIR, "clients", str(client_id), "logos")
_ensure_dir(logo_dir)
filename = f"{uuid.uuid4()}_{file.filename}"
file_path = os.path.join(logo_dir, filename)
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
# Update brand config
result = await session.execute(
select(BrandConfigModel).where(BrandConfigModel.client_id == client_id)
)
config = result.scalar_one_or_none()
if not config:
config = BrandConfigModel(client_id=client_id, logo_paths=[file_path])
else:
paths = config.logo_paths or []
paths.append(file_path)
config.logo_paths = paths
session.add(config)
await session.commit()
return {"message": "Logo uploaded", "path": file_path}
@BRAND_CONFIG_ROUTER.delete("/clients/{client_id}/brand/logo/{index}")
async def delete_logo(
client_id: uuid.UUID,
index: int,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_team_admin(admin, client_id, session)
result = await session.execute(
select(BrandConfigModel).where(BrandConfigModel.client_id == client_id)
)
config = result.scalar_one_or_none()
if not config or not config.logo_paths:
raise HTTPException(status_code=404, detail="No logos found")
if index < 0 or index >= len(config.logo_paths):
raise HTTPException(status_code=400, detail="Invalid logo index")
removed_path = config.logo_paths.pop(index)
# Try to delete the file
if os.path.exists(removed_path):
os.remove(removed_path)
session.add(config)
await session.commit()
return {"message": "Logo removed"}
@BRAND_CONFIG_ROUTER.post("/clients/{client_id}/brand/guideline")
async def upload_guideline(
client_id: uuid.UUID,
file: UploadFile = File(...),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_team_admin(admin, client_id, session)
guideline_dir = os.path.join(DATA_DIR, "clients", str(client_id), "guidelines")
_ensure_dir(guideline_dir)
filename = f"{uuid.uuid4()}_{file.filename}"
file_path = os.path.join(guideline_dir, filename)
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
result = await session.execute(
select(BrandConfigModel).where(BrandConfigModel.client_id == client_id)
)
config = result.scalar_one_or_none()
if not config:
config = BrandConfigModel(client_id=client_id, guideline_doc_path=file_path)
else:
config.guideline_doc_path = file_path
session.add(config)
await session.commit()
return {"message": "Guideline uploaded", "path": file_path}