- 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>
209 lines
6.6 KiB
Python
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}
|