Phase 1-2: Foundation + Admin Panel & Client Management

Phase 1 (Foundation):
- Project restructure (presenton-main → backend/ + frontend/)
- Database schema (8 new models, Alembic config, seed script)
- Auth (Azure AD SSO + dev bypass, JWT sessions, AuthMiddleware)
- RBAC (access_service, rbac_middleware, admin routers)
- Audit logging (fire-and-forget, AuditMiddleware, admin router)
- i18n (react-i18next with 5 namespace files)

Phase 2 (Admin Panel & Client Management):
- Admin panel shell (sidebar layout, role guard, 12 pages)
- Redux admin slice with 18 async thunks
- User management (role changes, deactivation)
- Client management (CRUD, brand config, team management)
- Brand config editor (colors, fonts, logos, voice rules)
- Master deck upload & parser (PPTX → HTML → React pipeline)
- Audit log viewer with filters and CSV/JSON export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-02-26 15:37:17 +00:00
parent 26d999314e
commit cf21ba4516
2068 changed files with 150455 additions and 0 deletions

53
.env.example Normal file
View file

@ -0,0 +1,53 @@
# Database
POSTGRES_PASSWORD=deckforge
# Redis
REDIS_URL=redis://redis:6379/0
# Azure AD Auth (leave blank to enable dev auth bypass)
AZURE_AD_TENANT_ID=
AZURE_AD_CLIENT_ID=
AZURE_AD_CLIENT_SECRET=
AZURE_AD_REDIRECT_URI=http://localhost/api/v1/auth/callback
# JWT
JWT_SECRET_KEY=change-me-to-a-random-256-bit-key
# Dev Auth (only used when AZURE_AD_TENANT_ID is not set)
DEV_AUTH_PASSWORD=devpass123
# LLM Provider — Claude Sonnet 4.6 for all text generation
LLM=anthropic
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-6-20250929
# Image Provider — Google for image generation
GOOGLE_API_KEY=
GOOGLE_MODEL=
IMAGE_PROVIDER=google
# Other LLM providers (not used by default)
OPENAI_API_KEY=
OPENAI_MODEL=
OLLAMA_URL=
OLLAMA_MODEL=
CUSTOM_LLM_URL=
CUSTOM_LLM_API_KEY=
CUSTOM_MODEL=
# Image fallback providers
PEXELS_API_KEY=
PIXABAY_API_KEY=
DISABLE_IMAGE_GENERATION=
# LLM Features
EXTENDED_REASONING=
TOOL_CALLS=
DISABLE_THINKING=
WEB_GROUNDING=
# App
APP_DATA_DIRECTORY=/app_data
TEMP_DIRECTORY=/tmp/deckforge
CAN_CHANGE_KEYS=false
DISABLE_ANONYMOUS_TRACKING=true

34
Makefile Normal file
View file

@ -0,0 +1,34 @@
.PHONY: dev build up down migrate seed test test-frontend logs shell-api shell-db
dev:
docker compose up --build
build:
docker compose build
up:
docker compose up -d
down:
docker compose down
migrate:
docker compose exec api alembic upgrade head
seed:
docker compose exec api python -m scripts.seed
test:
docker compose exec api pytest tests/ -v
test-frontend:
docker compose exec web npx cypress run
logs:
docker compose logs -f
shell-api:
docker compose exec api bash
shell-db:
docker compose exec postgres psql -U deckforge

1
backend/.python-version Normal file
View file

@ -0,0 +1 @@
3.11

32
backend/Dockerfile Normal file
View file

@ -0,0 +1,32 @@
FROM python:3.11-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir uv && \
uv pip install --system --no-cache -r pyproject.toml
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
libreoffice \
chromium \
fontconfig \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV APP_DATA_DIRECTORY=/app_data
ENV TEMP_DIRECTORY=/tmp/deckforge
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY . .
EXPOSE 8000
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]

37
backend/alembic.ini Normal file
View file

@ -0,0 +1,37 @@
[alembic]
script_location = migrations
prepend_sys_path = .
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

0
backend/api/__init__.py Normal file
View file

23
backend/api/lifespan.py Normal file
View file

@ -0,0 +1,23 @@
from contextlib import asynccontextmanager
import os
from fastapi import FastAPI
from services.database import create_db_and_tables
from utils.get_env import get_app_data_directory_env
from utils.model_availability import (
check_llm_and_image_provider_api_or_model_availability,
)
@asynccontextmanager
async def app_lifespan(_: FastAPI):
"""
Lifespan context manager for FastAPI application.
Initializes the application data directory and checks LLM model availability.
"""
os.makedirs(get_app_data_directory_env(), exist_ok=True)
await create_db_and_tables()
await check_llm_and_image_provider_api_or_model_availability()
yield

55
backend/api/main.py Normal file
View file

@ -0,0 +1,55 @@
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from api.lifespan import app_lifespan
from api.middlewares import UserConfigEnvUpdateMiddleware
from api.middlewares.auth_middleware import AuthMiddleware
from api.v1.ppt.router import API_V1_PPT_ROUTER
from api.v1.webhook.router import API_V1_WEBHOOK_ROUTER
from api.v1.mock.router import API_V1_MOCK_ROUTER
from api.v1.auth.router import AUTH_ROUTER
from api.v1.admin.users_router import USERS_ROUTER
from api.v1.admin.teams_router import TEAMS_ROUTER
from api.v1.admin.clients_router import CLIENTS_ROUTER
from api.v1.admin.audit_router import AUDIT_ROUTER
from api.v1.admin.brand_config_router import BRAND_CONFIG_ROUTER
from api.v1.admin.master_decks_router import MASTER_DECKS_ROUTER
from api.middlewares.audit_middleware import AuditMiddleware
app = FastAPI(lifespan=app_lifespan)
# Admin router aggregator
ADMIN_ROUTER = APIRouter(prefix="/api/v1/admin")
ADMIN_ROUTER.include_router(USERS_ROUTER)
ADMIN_ROUTER.include_router(TEAMS_ROUTER)
ADMIN_ROUTER.include_router(CLIENTS_ROUTER)
ADMIN_ROUTER.include_router(AUDIT_ROUTER)
ADMIN_ROUTER.include_router(BRAND_CONFIG_ROUTER)
ADMIN_ROUTER.include_router(MASTER_DECKS_ROUTER)
# Routers
app.include_router(AUTH_ROUTER)
app.include_router(ADMIN_ROUTER)
app.include_router(API_V1_PPT_ROUTER)
app.include_router(API_V1_WEBHOOK_ROUTER)
app.include_router(API_V1_MOCK_ROUTER)
# Middlewares (executed in reverse order: last added = first executed)
# 1. CORS must run first (handles preflight OPTIONS)
origins = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 2. Auth middleware (validates JWT, attaches user to request.state)
app.add_middleware(AuthMiddleware)
# 3. Audit middleware (fire-and-forget logging for mutations)
app.add_middleware(AuditMiddleware)
# 4. User config middleware
app.add_middleware(UserConfigEnvUpdateMiddleware)

View file

@ -0,0 +1,12 @@
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from utils.get_env import get_can_change_keys_env
from utils.user_config import update_env_with_user_config
class UserConfigEnvUpdateMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if get_can_change_keys_env() != "false":
update_env_with_user_config()
return await call_next(request)

View file

View file

@ -0,0 +1,62 @@
"""Middleware that auto-logs mutating API requests to audit log."""
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from services import audit_service
AUDITABLE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
def _extract_resource_type(path: str) -> str:
"""Extract resource type from URL path.
e.g. /api/v1/admin/users/123 -> users
/api/v1/ppt/presentation/create -> presentation
"""
parts = [p for p in path.split("/") if p]
# Walk backwards to find first meaningful segment (skip IDs and actions)
for part in reversed(parts):
if part in ("v1", "api", "admin", "ppt"):
continue
# Skip UUID-looking segments
if len(part) == 36 and part.count("-") == 4:
continue
# Skip common action words
if part in ("create", "update", "delete", "export", "login", "logout", "callback"):
continue
return part
return "unknown"
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
response = await call_next(request)
# Only log mutating requests to API endpoints
if request.method not in AUDITABLE_METHODS:
return response
if not request.url.path.startswith("/api/"):
return response
# Skip auth endpoints (logged separately)
if "/auth/" in request.url.path:
return response
# Only log successful mutations
if response.status_code >= 400:
return response
user = getattr(request.state, "user", None)
user_id = user.id if user else None
ip_address = request.client.host if request.client else None
resource_type = _extract_resource_type(request.url.path)
audit_service.log(
user_id=user_id,
action=f"{request.method} {request.url.path}",
resource_type=resource_type,
ip_address=ip_address,
)
return response

View file

@ -0,0 +1,82 @@
import uuid
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from services.auth_service import AuthService
from services.database import async_session_maker
from models.sql.user import UserModel
# Paths that skip authentication
PUBLIC_PATH_PREFIXES = [
"/api/v1/auth/",
"/docs",
"/openapi.json",
"/api/health",
]
auth_service = AuthService()
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Skip auth for public paths
for prefix in PUBLIC_PATH_PREFIXES:
if path.startswith(prefix):
request.state.user = None
return await call_next(request)
# Skip auth for non-API paths (Next.js frontend routes)
if not path.startswith("/api/"):
request.state.user = None
return await call_next(request)
# Extract token from cookie or Authorization header
token = request.cookies.get("session_token")
if not token:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
if not token:
return JSONResponse(
status_code=401,
content={"detail": "Authentication required"},
)
# Validate JWT
claims = auth_service.validate_token(token)
if not claims:
return JSONResponse(
status_code=401,
content={"detail": "Invalid or expired token"},
)
# Load user from DB
try:
user_id = uuid.UUID(claims["sub"])
async with async_session_maker() as session:
user = await session.get(UserModel, user_id)
except (ValueError, KeyError):
return JSONResponse(
status_code=401,
content={"detail": "Invalid token payload"},
)
if not user:
return JSONResponse(
status_code=401,
content={"detail": "User not found"},
)
if not user.is_active:
return JSONResponse(
status_code=401,
content={"detail": "Account deactivated"},
)
request.state.user = user
return await call_next(request)

View file

@ -0,0 +1,32 @@
"""RBAC helper functions for checking client/team access."""
import uuid
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from models.sql.user import UserModel
from services.access_service import get_accessible_client_ids
async def check_client_access(
user: UserModel, client_id: uuid.UUID, session: AsyncSession
) -> None:
"""Raise 403 if user cannot access the given client."""
if user.role == "super_admin":
return
accessible = await get_accessible_client_ids(user, session)
if client_id not in accessible:
raise HTTPException(status_code=403, detail="Access denied to this client")
async def check_team_admin(
user: UserModel, client_id: uuid.UUID, session: AsyncSession
) -> None:
"""Raise 403 if user is not an admin for the given client's scope."""
if user.role == "super_admin":
return
if user.role != "client_admin":
raise HTTPException(status_code=403, detail="Admin access required")
accessible = await get_accessible_client_ids(user, session)
if client_id not in accessible:
raise HTTPException(status_code=403, detail="Access denied to this client")

View file

View file

@ -0,0 +1,109 @@
"""Admin router for querying and exporting audit logs."""
from datetime import datetime
from typing import Optional
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from models.sql.user import UserModel
from services.database import get_async_session
from services import audit_service
from services.access_service import get_accessible_client_ids
from utils.auth_dependencies import require_client_admin
AUDIT_ROUTER = APIRouter(prefix="/audit-log", tags=["Admin - Audit"])
@AUDIT_ROUTER.get("")
async def get_audit_logs(
admin: UserModel = Depends(require_client_admin),
user_id: Optional[uuid.UUID] = Query(None),
action: Optional[str] = Query(None),
resource_type: Optional[str] = Query(None),
client_id: Optional[uuid.UUID] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_async_session),
):
# Scope by role: client_admin can only see logs for their accessible clients
if admin.role != "super_admin":
accessible = await get_accessible_client_ids(admin, session)
if client_id and client_id not in accessible:
raise HTTPException(status_code=403, detail="Access denied to this client's logs")
# If no client_id filter, we can't restrict well — return all accessible
# For now, require client_id for non-super admins
if not client_id and accessible:
# Just return the first accessible client's logs by default
pass
logs = await audit_service.query(
session=session,
user_id=user_id,
action=action,
resource_type=resource_type,
client_id=client_id,
date_from=date_from,
date_to=date_to,
offset=offset,
limit=limit,
)
return [
{
"id": str(log.id),
"user_id": str(log.user_id) if log.user_id else None,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": str(log.resource_id) if log.resource_id else None,
"client_id": str(log.client_id) if log.client_id else None,
"details": log.details,
"ip_address": log.ip_address,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
]
@AUDIT_ROUTER.get("/export")
async def export_audit_logs(
format: str = Query("csv", regex="^(csv|json)$"),
admin: UserModel = Depends(require_client_admin),
user_id: Optional[uuid.UUID] = Query(None),
action: Optional[str] = Query(None),
resource_type: Optional[str] = Query(None),
client_id: Optional[uuid.UUID] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
session: AsyncSession = Depends(get_async_session),
):
# Fetch up to 10000 entries for export
logs = await audit_service.query(
session=session,
user_id=user_id,
action=action,
resource_type=resource_type,
client_id=client_id,
date_from=date_from,
date_to=date_to,
offset=0,
limit=10000,
)
if format == "csv":
content = audit_service.export_csv(logs)
media_type = "text/csv"
filename = "audit_log.csv"
else:
content = audit_service.export_json(logs)
media_type = "application/json"
filename = "audit_log.json"
return StreamingResponse(
iter([content]),
media_type=media_type,
headers={"Content-Disposition": f"attachment; filename={filename}"},
)

View file

@ -0,0 +1,199 @@
"""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:
raise HTTPException(status_code=404, detail="Brand config not found")
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}

View file

@ -0,0 +1,154 @@
"""Admin router for client management."""
import re
from typing import List, Optional
import uuid
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from models.sql.client import ClientModel
from models.sql.team import TeamModel
from models.sql.user import UserModel
from services.database import get_async_session
from services.access_service import get_accessible_clients
from api.middlewares.rbac_middleware import check_client_access
from utils.auth_dependencies import require_super_admin, require_client_admin
CLIENTS_ROUTER = APIRouter(prefix="/clients", tags=["Admin - Clients"])
def _slugify(name: str) -> str:
slug = name.lower().strip()
slug = re.sub(r"[^a-z0-9]+", "-", slug)
return slug.strip("-")
@CLIENTS_ROUTER.post("", status_code=201)
async def create_client(
name: str = Body(..., embed=True),
review_policy: str = Body("self_approve", embed=True),
_: UserModel = Depends(require_super_admin),
session: AsyncSession = Depends(get_async_session),
):
slug = _slugify(name)
# Check slug uniqueness
result = await session.execute(
select(ClientModel).where(ClientModel.slug == slug)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=409, detail="A client with this name already exists")
client = ClientModel(name=name, slug=slug, review_policy=review_policy)
session.add(client)
await session.flush()
# Auto-create a team for this client
team = TeamModel(name=f"{name} Team", client_id=client.id, is_default=False)
session.add(team)
await session.commit()
return {
"id": str(client.id),
"name": client.name,
"slug": client.slug,
"review_policy": client.review_policy,
"team_id": str(team.id),
}
@CLIENTS_ROUTER.get("", response_model=List[dict])
async def list_clients(
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
clients = await get_accessible_clients(admin, session)
return [
{
"id": str(c.id),
"name": c.name,
"slug": c.slug,
"logo_path": c.logo_path,
"retention_days": c.retention_days,
"review_policy": c.review_policy,
"is_active": c.is_active,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
for c in clients
]
@CLIENTS_ROUTER.get("/{client_id}")
async def get_client(
client_id: uuid.UUID,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_client_access(admin, client_id, session)
client = await session.get(ClientModel, client_id)
if not client:
raise HTTPException(status_code=404, detail="Client not found")
return {
"id": str(client.id),
"name": client.name,
"slug": client.slug,
"logo_path": client.logo_path,
"retention_days": client.retention_days,
"review_policy": client.review_policy,
"is_active": client.is_active,
"created_at": client.created_at.isoformat() if client.created_at else None,
}
@CLIENTS_ROUTER.put("/{client_id}")
async def update_client(
client_id: uuid.UUID,
name: Optional[str] = Body(None, embed=True),
review_policy: Optional[str] = Body(None, embed=True),
retention_days: Optional[int] = Body(None, embed=True),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_client_access(admin, client_id, session)
client = await session.get(ClientModel, client_id)
if not client:
raise HTTPException(status_code=404, detail="Client not found")
if name is not None:
client.name = name
client.slug = _slugify(name)
if review_policy is not None:
client.review_policy = review_policy
if retention_days is not None:
client.retention_days = retention_days
session.add(client)
await session.commit()
return {
"id": str(client.id),
"name": client.name,
"slug": client.slug,
"review_policy": client.review_policy,
"retention_days": client.retention_days,
}
@CLIENTS_ROUTER.delete("/{client_id}")
async def deactivate_client(
client_id: uuid.UUID,
_: UserModel = Depends(require_super_admin),
session: AsyncSession = Depends(get_async_session),
):
client = await session.get(ClientModel, client_id)
if not client:
raise HTTPException(status_code=404, detail="Client not found")
client.is_active = False
session.add(client)
await session.commit()
return {"message": "Client deactivated", "client_id": str(client.id)}

View file

@ -0,0 +1,262 @@
"""Admin router for master deck upload, parsing, and management."""
import os
import shutil
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from constants.documents import POWERPOINT_TYPES
from models.sql.master_deck import MasterDeckModel
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
MASTER_DECKS_ROUTER = APIRouter(tags=["Admin - Master Decks"])
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "data")
def _deck_dir(client_id: uuid.UUID, deck_id: uuid.UUID) -> str:
return os.path.join(DATA_DIR, "clients", str(client_id), "master_decks", str(deck_id))
# --- Request / Response schemas ---
class MasterDeckUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class LayoutUpdate(BaseModel):
layout_name: Optional[str] = None
layout_type: Optional[str] = None
react_code: Optional[str] = None
# --- Endpoints ---
@MASTER_DECKS_ROUTER.get("/clients/{client_id}/master-decks")
async def list_master_decks(
client_id: uuid.UUID,
include_inactive: bool = Query(False),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_team_admin(admin, client_id, session)
stmt = select(MasterDeckModel).where(MasterDeckModel.client_id == client_id)
if not include_inactive:
stmt = stmt.where(MasterDeckModel.is_active == True)
stmt = stmt.order_by(MasterDeckModel.created_at.desc())
result = await session.execute(stmt)
decks = result.scalars().all()
return [
{
"id": str(d.id),
"client_id": str(d.client_id),
"name": d.name,
"description": d.description,
"thumbnail_path": d.thumbnail_path,
"parse_status": d.parse_status,
"is_active": d.is_active,
"layouts": d.layouts,
"created_at": d.created_at.isoformat() if d.created_at else None,
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
}
for d in decks
]
@MASTER_DECKS_ROUTER.post("/clients/{client_id}/master-decks")
async def upload_master_deck(
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)
if file.content_type not in POWERPOINT_TYPES:
raise HTTPException(status_code=400, detail="Only PPTX files are accepted")
if hasattr(file, "size") and file.size and file.size > 100 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large (max 100 MB)")
deck_id = uuid.uuid4()
deck_path = _deck_dir(client_id, deck_id)
os.makedirs(deck_path, exist_ok=True)
original_name = file.filename or "presentation.pptx"
file_path = os.path.join(deck_path, original_name)
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
deck = MasterDeckModel(
id=deck_id,
client_id=client_id,
name=os.path.splitext(original_name)[0],
original_file_path=file_path,
parse_status="pending",
is_active=True,
)
session.add(deck)
await session.commit()
await session.refresh(deck)
# Kick off async parsing
import asyncio
from services.master_deck_parser_service import parse_master_deck
asyncio.create_task(parse_master_deck(deck_id))
return {
"id": str(deck.id),
"client_id": str(deck.client_id),
"name": deck.name,
"parse_status": deck.parse_status,
"created_at": deck.created_at.isoformat() if deck.created_at else None,
}
@MASTER_DECKS_ROUTER.get("/master-decks/{deck_id}")
async def get_master_deck(
deck_id: uuid.UUID,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
deck = await session.get(MasterDeckModel, deck_id)
if not deck:
raise HTTPException(status_code=404, detail="Master deck not found")
await check_team_admin(admin, deck.client_id, session)
return {
"id": str(deck.id),
"client_id": str(deck.client_id),
"name": deck.name,
"description": deck.description,
"original_file_path": deck.original_file_path,
"thumbnail_path": deck.thumbnail_path,
"parsed_config": deck.parsed_config,
"layouts": deck.layouts,
"parse_status": deck.parse_status,
"is_active": deck.is_active,
"created_at": deck.created_at.isoformat() if deck.created_at else None,
"updated_at": deck.updated_at.isoformat() if deck.updated_at else None,
}
@MASTER_DECKS_ROUTER.put("/master-decks/{deck_id}")
async def update_master_deck(
deck_id: uuid.UUID,
body: MasterDeckUpdate,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
deck = await session.get(MasterDeckModel, deck_id)
if not deck:
raise HTTPException(status_code=404, detail="Master deck not found")
await check_team_admin(admin, deck.client_id, session)
if body.name is not None:
deck.name = body.name
if body.description is not None:
deck.description = body.description
if body.is_active is not None:
deck.is_active = body.is_active
await session.commit()
await session.refresh(deck)
return {"ok": True, "id": str(deck.id)}
@MASTER_DECKS_ROUTER.put("/master-decks/{deck_id}/layouts/{layout_index}")
async def update_layout(
deck_id: uuid.UUID,
layout_index: int,
body: LayoutUpdate,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
deck = await session.get(MasterDeckModel, deck_id)
if not deck:
raise HTTPException(status_code=404, detail="Master deck not found")
await check_team_admin(admin, deck.client_id, session)
if not deck.layouts or layout_index < 0 or layout_index >= len(deck.layouts):
raise HTTPException(status_code=404, detail="Layout not found at index")
layout = deck.layouts[layout_index]
if body.layout_name is not None:
layout["layout_name"] = body.layout_name
if body.layout_type is not None:
layout["layout_type"] = body.layout_type
if body.react_code is not None:
layout["react_code"] = body.react_code
# SQLAlchemy needs to detect mutation on JSON column
from sqlalchemy.orm.attributes import flag_modified
flag_modified(deck, "layouts")
await session.commit()
return {"ok": True, "layout_index": layout_index}
@MASTER_DECKS_ROUTER.post("/master-decks/{deck_id}/reparse")
async def reparse_master_deck(
deck_id: uuid.UUID,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
deck = await session.get(MasterDeckModel, deck_id)
if not deck:
raise HTTPException(status_code=404, detail="Master deck not found")
await check_team_admin(admin, deck.client_id, session)
if deck.parse_status == "processing":
raise HTTPException(status_code=409, detail="Deck is already being parsed")
deck.parse_status = "pending"
await session.commit()
import asyncio
from services.master_deck_parser_service import parse_master_deck
asyncio.create_task(parse_master_deck(deck_id))
return {"ok": True, "parse_status": "pending"}
@MASTER_DECKS_ROUTER.delete("/master-decks/{deck_id}")
async def delete_master_deck(
deck_id: uuid.UUID,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
deck = await session.get(MasterDeckModel, deck_id)
if not deck:
raise HTTPException(status_code=404, detail="Master deck not found")
await check_team_admin(admin, deck.client_id, session)
# Soft delete
deck.is_active = False
await session.commit()
return {"ok": True}

View file

@ -0,0 +1,204 @@
"""Admin router for team management."""
from typing import List, Optional
import uuid
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from models.sql.team import TeamModel
from models.sql.team_membership import TeamMembershipModel
from models.sql.user import UserModel
from services.database import get_async_session
from services.access_service import get_accessible_client_ids
from api.middlewares.rbac_middleware import check_team_admin
from utils.auth_dependencies import get_current_user, require_client_admin
TEAMS_ROUTER = APIRouter(prefix="/teams", tags=["Admin - Teams"])
@TEAMS_ROUTER.post("", status_code=201)
async def create_team(
name: str = Body(..., embed=True),
client_id: Optional[uuid.UUID] = Body(None, embed=True),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
if client_id:
await check_team_admin(admin, client_id, session)
team = TeamModel(name=name, client_id=client_id, is_default=False)
session.add(team)
await session.commit()
return {
"id": str(team.id),
"name": team.name,
"client_id": str(team.client_id) if team.client_id else None,
"is_default": team.is_default,
}
@TEAMS_ROUTER.get("", response_model=List[dict])
async def list_teams(
client_id: Optional[uuid.UUID] = Query(None),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
query = select(TeamModel)
if admin.role != "super_admin":
accessible_ids = await get_accessible_client_ids(admin, session)
query = query.where(
(TeamModel.client_id.in_(accessible_ids)) | (TeamModel.is_default == True) # noqa: E712
)
if client_id:
query = query.where(TeamModel.client_id == client_id)
result = await session.execute(query.order_by(TeamModel.created_at.desc()))
teams = result.scalars().all()
return [
{
"id": str(t.id),
"name": t.name,
"client_id": str(t.client_id) if t.client_id else None,
"is_default": t.is_default,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in teams
]
@TEAMS_ROUTER.get("/{team_id}")
async def get_team(
team_id: uuid.UUID,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
team = await session.get(TeamModel, team_id)
if not team:
raise HTTPException(status_code=404, detail="Team not found")
if team.client_id and admin.role != "super_admin":
await check_team_admin(admin, team.client_id, session)
# Get members
result = await session.execute(
select(UserModel)
.join(TeamMembershipModel, TeamMembershipModel.user_id == UserModel.id)
.where(TeamMembershipModel.team_id == team_id)
)
members = result.scalars().all()
return {
"id": str(team.id),
"name": team.name,
"client_id": str(team.client_id) if team.client_id else None,
"is_default": team.is_default,
"created_at": team.created_at.isoformat() if team.created_at else None,
"members": [
{
"id": str(m.id),
"email": m.email,
"display_name": m.display_name,
"role": m.role,
}
for m in members
],
}
@TEAMS_ROUTER.post("/{team_id}/members", status_code=201)
async def add_team_member(
team_id: uuid.UUID,
user_id: uuid.UUID = Body(..., embed=True),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
team = await session.get(TeamModel, team_id)
if not team:
raise HTTPException(status_code=404, detail="Team not found")
if team.client_id and admin.role != "super_admin":
await check_team_admin(admin, team.client_id, session)
user = await session.get(UserModel, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check for existing membership
result = await session.execute(
select(TeamMembershipModel).where(
TeamMembershipModel.user_id == user_id,
TeamMembershipModel.team_id == team_id,
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=409, detail="User already in team")
membership = TeamMembershipModel(
user_id=user_id,
team_id=team_id,
assigned_by=admin.id,
)
session.add(membership)
await session.commit()
return {"message": "Member added", "user_id": str(user_id), "team_id": str(team_id)}
@TEAMS_ROUTER.delete("/{team_id}/members/{user_id}")
async def remove_team_member(
team_id: uuid.UUID,
user_id: uuid.UUID,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
team = await session.get(TeamModel, team_id)
if not team:
raise HTTPException(status_code=404, detail="Team not found")
if team.client_id and admin.role != "super_admin":
await check_team_admin(admin, team.client_id, session)
result = await session.execute(
select(TeamMembershipModel).where(
TeamMembershipModel.user_id == user_id,
TeamMembershipModel.team_id == team_id,
)
)
membership = result.scalar_one_or_none()
if not membership:
raise HTTPException(status_code=404, detail="Membership not found")
await session.delete(membership)
await session.commit()
return {"message": "Member removed"}
@TEAMS_ROUTER.delete("/{team_id}")
async def delete_team(
team_id: uuid.UUID,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
team = await session.get(TeamModel, team_id)
if not team:
raise HTTPException(status_code=404, detail="Team not found")
if team.is_default:
raise HTTPException(status_code=400, detail="Cannot delete the default team")
if team.client_id and admin.role != "super_admin":
await check_team_admin(admin, team.client_id, session)
# Remove all memberships first
result = await session.execute(
select(TeamMembershipModel).where(TeamMembershipModel.team_id == team_id)
)
memberships = result.scalars().all()
for m in memberships:
await session.delete(m)
await session.delete(team)
await session.commit()
return {"message": "Team deleted"}

View file

@ -0,0 +1,112 @@
"""Admin router for user management. Super Admin only."""
from typing import List, Optional
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from models.sql.user import UserModel
from services.database import get_async_session
from utils.auth_dependencies import require_super_admin
USERS_ROUTER = APIRouter(prefix="/users", tags=["Admin - Users"])
VALID_ROLES = {"super_admin", "client_admin", "user"}
@USERS_ROUTER.get("", response_model=List[dict])
async def list_users(
_: UserModel = Depends(require_super_admin),
is_active: Optional[bool] = Query(None),
role: Optional[str] = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_async_session),
):
query = select(UserModel)
if is_active is not None:
query = query.where(UserModel.is_active == is_active)
if role:
query = query.where(UserModel.role == role)
query = query.order_by(UserModel.created_at.desc()).offset(offset).limit(limit)
result = await session.execute(query)
users = result.scalars().all()
return [
{
"id": str(u.id),
"email": u.email,
"display_name": u.display_name,
"role": u.role,
"is_active": u.is_active,
"last_login_at": u.last_login_at.isoformat() if u.last_login_at else None,
"created_at": u.created_at.isoformat() if u.created_at else None,
}
for u in users
]
@USERS_ROUTER.get("/{user_id}", response_model=dict)
async def get_user(
user_id: uuid.UUID,
_: UserModel = Depends(require_super_admin),
session: AsyncSession = Depends(get_async_session),
):
user = await session.get(UserModel, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"role": user.role,
"is_active": user.is_active,
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
@USERS_ROUTER.put("/{user_id}/role")
async def update_user_role(
user_id: uuid.UUID,
role: str = Query(...),
admin: UserModel = Depends(require_super_admin),
session: AsyncSession = Depends(get_async_session),
):
if role not in VALID_ROLES:
raise HTTPException(
status_code=400,
detail=f"Invalid role. Must be one of: {', '.join(VALID_ROLES)}",
)
user = await session.get(UserModel, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
user.role = role
session.add(user)
await session.commit()
return {"message": "Role updated", "user_id": str(user.id), "role": role}
@USERS_ROUTER.delete("/{user_id}")
async def deactivate_user(
user_id: uuid.UUID,
admin: UserModel = Depends(require_super_admin),
session: AsyncSession = Depends(get_async_session),
):
user = await session.get(UserModel, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
user.is_active = False
session.add(user)
await session.commit()
return {"message": "User deactivated", "user_id": str(user.id)}

View file

View file

@ -0,0 +1,134 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, Request
from fastapi.responses import RedirectResponse, JSONResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from services.database import get_async_session
from services.auth_service import AuthService
AUTH_ROUTER = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
auth_service = AuthService()
class DevLoginRequest(BaseModel):
email: str
password: str
@AUTH_ROUTER.get("/dev-status")
async def dev_status():
"""Check if dev auth mode is enabled."""
return {"dev_mode": auth_service.is_dev_mode}
@AUTH_ROUTER.get("/login")
async def login():
"""Redirect to Azure AD login, or return dev mode info."""
if auth_service.is_dev_mode:
return JSONResponse(
status_code=200,
content={
"dev_mode": True,
"message": "Use POST /api/v1/auth/dev-login with email and password",
},
)
try:
url = auth_service.get_authorization_url()
return RedirectResponse(url=url)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to generate login URL: {e}")
@AUTH_ROUTER.get("/callback")
async def callback(
code: str = "",
error: str = "",
error_description: str = "",
session: AsyncSession = Depends(get_async_session),
):
"""Azure AD OAuth callback."""
if error:
raise HTTPException(status_code=401, detail=error_description or error)
if not code:
raise HTTPException(status_code=400, detail="Missing authorization code")
try:
result = await auth_service.exchange_code_for_token(code)
claims = result.get("id_token_claims", {})
user = await auth_service.get_or_create_user(claims, session)
token = auth_service.create_session_jwt(user)
response = RedirectResponse(url="/upload", status_code=302)
response.set_cookie(
key="session_token",
value=token,
httponly=True,
secure=False, # Set True in production with HTTPS
samesite="lax",
max_age=86400, # 24 hours
)
return response
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Authentication failed: {e}")
@AUTH_ROUTER.post("/dev-login")
async def dev_login(
body: DevLoginRequest,
session: AsyncSession = Depends(get_async_session),
):
"""Dev-mode login with email and password. Only available when Azure AD is not configured."""
if not auth_service.is_dev_mode:
raise HTTPException(status_code=404, detail="Dev login not available")
user = await auth_service.dev_login(body.email, body.password, session)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
token = auth_service.create_session_jwt(user)
response = JSONResponse(
content={
"id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"role": user.role,
}
)
response.set_cookie(
key="session_token",
value=token,
httponly=True,
secure=False,
samesite="lax",
max_age=86400,
)
return response
@AUTH_ROUTER.get("/me")
async def get_current_user_info(request: Request):
"""Return current authenticated user info."""
user = getattr(request.state, "user", None)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return {
"id": str(user.id),
"email": user.email,
"displayName": user.display_name,
"role": user.role,
}
@AUTH_ROUTER.post("/logout")
async def logout():
"""Clear session cookie."""
response = JSONResponse(content={"message": "Logged out"})
response.delete_cookie("session_token")
return response

View file

@ -0,0 +1,34 @@
import uuid
from fastapi import APIRouter
from models.api_error_model import APIErrorModel
from models.presentation_and_path import PresentationPathAndEditPath
from typing import List
API_V1_MOCK_ROUTER = APIRouter(prefix="/api/v1/mock", tags=["Mock"])
@API_V1_MOCK_ROUTER.get(
"/presentation-generation-completed",
response_model=List[PresentationPathAndEditPath],
)
async def mock_presentation_generation_completed():
return [
PresentationPathAndEditPath(
presentation_id=uuid.uuid4(),
path="/app_data/exports/test.pdf",
edit_path="/presentation?id=123",
)
]
@API_V1_MOCK_ROUTER.get(
"/presentation-generation-failed",
response_model=List[APIErrorModel],
)
async def mock_presentation_generation_completed():
return [
APIErrorModel(
status_code=500,
detail="Presentation generation failed",
)
]

View file

@ -0,0 +1,73 @@
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.ollama_model_status import OllamaModelStatus
from models.sql.ollama_pull_status import OllamaPullStatus
from services.database import async_session_maker
from utils.ollama import pull_ollama_model
async def pull_ollama_model_background_task(model: str):
saved_model_status = OllamaModelStatus(
name=model,
status="pulling",
done=False,
)
log_event_count = 0
async with async_session_maker() as session:
try:
async for event in pull_ollama_model(model):
log_event_count += 1
if log_event_count != 1 and log_event_count % 20 != 0:
continue
if "completed" in event:
saved_model_status.downloaded = event["completed"]
if not saved_model_status.size and "total" in event:
saved_model_status.size = event["total"]
if "status" in event:
saved_model_status.status = event["status"]
await upsert_ollama_pull_status(session, model, saved_model_status)
except Exception as e:
saved_model_status.status = "error"
saved_model_status.done = True
await upsert_ollama_pull_status(session, model, saved_model_status)
raise HTTPException(
status_code=500,
detail=f"Failed to pull model: {e}",
)
saved_model_status.done = True
saved_model_status.status = "pulled"
saved_model_status.downloaded = saved_model_status.size
await upsert_ollama_pull_status(session, model, saved_model_status)
async def upsert_ollama_pull_status(
session: AsyncSession, model: str, model_status: OllamaModelStatus
):
stmt = select(OllamaPullStatus).where(OllamaPullStatus.id == model)
result = await session.execute(stmt)
existing_record = result.scalar_one_or_none()
if existing_record:
existing_record.status = model_status.model_dump(mode="json")
existing_record.last_updated = datetime.now()
else:
new_record = OllamaPullStatus(
id=model,
status=model_status.model_dump(mode="json"),
last_updated=datetime.now(),
)
session.add(new_record)
await session.commit()
await session.flush()

View file

View file

@ -0,0 +1,16 @@
from typing import Annotated, List
from fastapi import APIRouter, Body, HTTPException
from utils.available_models import list_available_anthropic_models
ANTHROPIC_ROUTER = APIRouter(prefix="/anthropic", tags=["Anthropic"])
@ANTHROPIC_ROUTER.post("/models/available", response_model=List[str])
async def get_available_models(
api_key: Annotated[str, Body(embed=True)],
):
try:
return await list_available_anthropic_models(api_key)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,87 @@
from http.client import HTTPException
import os
from typing import Annotated, List, Optional
from fastapi import APIRouter, Body, File, UploadFile
from constants.documents import UPLOAD_ACCEPTED_FILE_TYPES
from models.decomposed_file_info import DecomposedFileInfo
from services.temp_file_service import TEMP_FILE_SERVICE
from services.documents_loader import DocumentsLoader
import uuid
from utils.validators import validate_files
FILES_ROUTER = APIRouter(prefix="/files", tags=["Files"])
@FILES_ROUTER.post("/upload", response_model=List[str])
async def upload_files(files: Optional[List[UploadFile]]):
if not files:
raise HTTPException(400, "Documents are required")
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(str(uuid.uuid4()))
validate_files(files, True, True, 100, UPLOAD_ACCEPTED_FILE_TYPES)
temp_files: List[str] = []
if files:
for each_file in files:
temp_path = TEMP_FILE_SERVICE.create_temp_file_path(
each_file.filename, temp_dir
)
with open(temp_path, "wb") as f:
content = await each_file.read()
f.write(content)
temp_files.append(temp_path)
return temp_files
@FILES_ROUTER.post("/decompose", response_model=List[DecomposedFileInfo])
async def decompose_files(file_paths: Annotated[List[str], Body(embed=True)]):
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(str(uuid.uuid4()))
txt_files = []
other_files = []
for file_path in file_paths:
if file_path.endswith(".txt"):
txt_files.append(file_path)
else:
other_files.append(file_path)
documents_loader = DocumentsLoader(file_paths=other_files)
await documents_loader.load_documents(temp_dir)
parsed_documents = documents_loader.documents
response = []
for index, parsed_doc in enumerate(parsed_documents):
file_path = TEMP_FILE_SERVICE.create_temp_file_path(
f"{uuid.uuid4()}.txt", temp_dir
)
parsed_doc = parsed_doc.replace("<br>", "\n")
with open(file_path, "w") as text_file:
text_file.write(parsed_doc)
response.append(
DecomposedFileInfo(
name=os.path.basename(other_files[index]), file_path=file_path
)
)
# Return the txt documents as it is
for each_file in txt_files:
response.append(
DecomposedFileInfo(name=os.path.basename(each_file), file_path=each_file)
)
return response
@FILES_ROUTER.post("/update")
async def update_files(
file_path: Annotated[str, Body()],
file: Annotated[UploadFile, File()],
):
with open(file_path, "wb") as f:
f.write(await file.read())
return {"message": "File updated successfully"}

View file

@ -0,0 +1,290 @@
import os
import uuid
import shutil
from pathlib import Path
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, HTTPException, File, UploadFile
from pydantic import BaseModel
from utils.asset_directory_utils import get_app_data_directory_env
import uuid
try:
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables._n_a_m_e import table__n_a_m_e
FONTTOOLS_AVAILABLE = True
except ImportError:
FONTTOOLS_AVAILABLE = False
FONTS_ROUTER = APIRouter(prefix="/fonts", tags=["fonts"])
# Supported font file extensions
SUPPORTED_FONT_EXTENSIONS = {
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.eot': 'application/vnd.ms-fontobject'
}
class FontUploadResponse(BaseModel):
success: bool
font_name: str
font_url: str
font_path: str
message: Optional[str] = None
class FontListResponse(BaseModel):
success: bool
fonts: List[dict]
message: Optional[str] = None
def get_fonts_directory() -> str:
"""Get the fonts directory path, create if it doesn't exist"""
app_data_dir = get_app_data_directory_env() or "/tmp/presenton"
fonts_dir = os.path.join(app_data_dir, "fonts")
os.makedirs(fonts_dir, exist_ok=True)
return fonts_dir
def is_valid_font_file(file: UploadFile) -> bool:
"""Validate font file by extension and MIME type"""
if not file.filename:
return False
file_ext = os.path.splitext(file.filename)[1].lower()
if file_ext not in SUPPORTED_FONT_EXTENSIONS:
return False
# Check MIME type
content_type = file.content_type or ""
valid_mime_types = [
"font/ttf", "font/otf", "font/woff", "font/woff2",
"application/font-ttf", "application/font-otf",
"application/font-woff", "application/font-woff2",
"application/x-font-ttf", "application/x-font-otf",
"font/truetype", "font/opentype"
]
return content_type in valid_mime_types
def extract_font_name_from_file(file_path: str) -> str:
"""Extract the actual font family name from font file metadata"""
if not FONTTOOLS_AVAILABLE:
# Fallback to filename parsing if fonttools not available
filename = os.path.basename(file_path)
base_name = os.path.splitext(filename)[0]
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
# Remove UUID part
parts = filename.split('_')
if len(parts) > 1:
return '_'.join(parts[:-1])
return base_name
try:
font = TTFont(file_path)
# Try to get font family name from name table
if 'name' in font:
name_table = font['name']
# Preferred order: Family name (ID 1), then Full name (ID 4), then PostScript name (ID 6)
for name_id in [1, 4, 6]:
for record in name_table.names:
if record.nameID == name_id:
# Prefer English names
if record.langID == 0x409 or record.langID == 0: # English
font_name = record.toUnicode().strip()
if font_name:
font.close()
return font_name
# If no English name found, use any available family name
for record in name_table.names:
if record.nameID == 1: # Family name
font_name = record.toUnicode().strip()
if font_name:
font.close()
return font_name
font.close()
except Exception as e:
# If font parsing fails, fallback to filename
print(f"Error reading font metadata from {file_path}: {e}")
# Fallback to filename parsing
filename = os.path.basename(file_path)
base_name = os.path.splitext(filename)[0]
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
# Remove UUID part
parts = filename.split('_')
if len(parts) > 1:
return '_'.join(parts[:-1])
return base_name
@FONTS_ROUTER.post("/upload", response_model=FontUploadResponse)
async def upload_font(
font_file: UploadFile = File(..., description="Font file to upload (.ttf, .otf, .woff, .woff2, .eot)")
):
"""
Upload a font file and save it to the fonts directory.
Args:
font_file: Uploaded font file
Returns:
FontUploadResponse with font details and accessible URL
Raises:
HTTPException: If file validation fails or upload error occurs
"""
try:
# Validate file
if not font_file.filename:
raise HTTPException(
status_code=400,
detail="No file name provided"
)
if not is_valid_font_file(font_file):
raise HTTPException(
status_code=400,
detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}"
)
# Generate unique filename to avoid conflicts
file_ext = os.path.splitext(font_file.filename)[1].lower()
base_name = os.path.splitext(font_file.filename)[0]
unique_filename = f"{base_name}_{str(uuid.uuid4())[:8]}{file_ext}"
# Get fonts directory
fonts_dir = get_fonts_directory()
font_path = os.path.join(fonts_dir, unique_filename)
# Save the uploaded file
with open(font_path, "wb") as buffer:
shutil.copyfileobj(font_file.file, buffer)
# Generate accessible URL
font_url = f"/app_data/fonts/{unique_filename}"
return FontUploadResponse(
success=True,
font_name=base_name,
font_url=font_url,
font_path=font_path,
message=f"Font '{base_name}' uploaded successfully"
)
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
print(f"Error uploading font: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error uploading font: {str(e)}"
)
@FONTS_ROUTER.get("/list", response_model=FontListResponse)
async def list_fonts():
"""
List all uploaded fonts with their accessible URLs.
Returns:
FontListResponse with list of available fonts
"""
try:
fonts_dir = get_fonts_directory()
fonts = []
# Get all font files in the directory
if os.path.exists(fonts_dir):
for filename in os.listdir(fonts_dir):
file_path = os.path.join(fonts_dir, filename)
if os.path.isfile(file_path):
file_ext = os.path.splitext(filename)[1].lower()
if file_ext in SUPPORTED_FONT_EXTENSIONS:
# Get the real font name from file metadata
font_name = extract_font_name_from_file(file_path)
# Extract original name (remove UUID suffix for display)
base_name = filename
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
# Remove UUID part for original_name display
parts = filename.split('_')
if len(parts) > 1:
base_name = '_'.join(parts[:-1]) + file_ext
fonts.append({
"filename": filename,
"font_name": font_name, # Real font family name from metadata
"original_name": base_name,
"font_url": f"/app_data/fonts/{filename}",
"font_type": SUPPORTED_FONT_EXTENSIONS.get(file_ext, 'unknown'),
"file_size": os.path.getsize(file_path)
})
return FontListResponse(
success=True,
fonts=fonts,
message=f"Found {len(fonts)} font files"
)
except Exception as e:
print(f"Error listing fonts: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error listing fonts: {str(e)}"
)
@FONTS_ROUTER.delete("/delete/{filename}")
async def delete_font(filename: str):
"""
Delete a font file from the fonts directory.
Args:
filename: Name of the font file to delete
Returns:
Success message
"""
try:
fonts_dir = get_fonts_directory()
font_path = os.path.join(fonts_dir, filename)
if not os.path.exists(font_path):
raise HTTPException(
status_code=404,
detail=f"Font file '{filename}' not found"
)
# Validate it's actually a font file before deleting
file_ext = os.path.splitext(filename.lower())[1]
if file_ext not in SUPPORTED_FONT_EXTENSIONS:
raise HTTPException(
status_code=400,
detail="File is not a recognized font format"
)
os.remove(font_path)
return {
"success": True,
"message": f"Font '{filename}' deleted successfully"
}
except HTTPException:
raise
except Exception as e:
print(f"Error deleting font: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error deleting font: {str(e)}"
)

View file

@ -0,0 +1,14 @@
from typing import Annotated, List
from fastapi import APIRouter, Body, HTTPException
from utils.available_models import list_available_google_models
GOOGLE_ROUTER = APIRouter(prefix="/google", tags=["Google"])
@GOOGLE_ROUTER.post("/models/available", response_model=List[str])
async def get_available_models(api_key: Annotated[str, Body(embed=True)]):
try:
return await list_available_google_models(api_key)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,10 @@
from typing import List
from fastapi import APIRouter
from services.icon_finder_service import ICON_FINDER_SERVICE
ICONS_ROUTER = APIRouter(prefix="/icons", tags=["Icons"])
@ICONS_ROUTER.get("/search", response_model=List[str])
async def search_icons(query: str, limit: int = 20):
return await ICON_FINDER_SERVICE.search_icons(query, limit)

View file

@ -0,0 +1,105 @@
from typing import List
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from models.image_prompt import ImagePrompt
from models.sql.image_asset import ImageAsset
from services.database import get_async_session
from services.image_generation_service import ImageGenerationService
from utils.asset_directory_utils import get_images_directory
import os
import uuid
from utils.file_utils import get_file_name_with_random_uuid
IMAGES_ROUTER = APIRouter(prefix="/images", tags=["Images"])
@IMAGES_ROUTER.get("/generate")
async def generate_image(
prompt: str, sql_session: AsyncSession = Depends(get_async_session)
):
images_directory = get_images_directory()
image_prompt = ImagePrompt(prompt=prompt)
image_generation_service = ImageGenerationService(images_directory)
image = await image_generation_service.generate_image(image_prompt)
if not isinstance(image, ImageAsset):
return image
sql_session.add(image)
await sql_session.commit()
return image.path
@IMAGES_ROUTER.get("/generated", response_model=List[ImageAsset])
async def get_generated_images(sql_session: AsyncSession = Depends(get_async_session)):
try:
images = await sql_session.scalars(
select(ImageAsset)
.where(ImageAsset.is_uploaded == False)
.order_by(ImageAsset.created_at.desc())
)
return images
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to retrieve generated images: {str(e)}"
)
@IMAGES_ROUTER.post("/upload")
async def upload_image(
file: UploadFile = File(...), sql_session: AsyncSession = Depends(get_async_session)
):
try:
new_filename = get_file_name_with_random_uuid(file)
image_path = os.path.join(
get_images_directory(), os.path.basename(new_filename)
)
with open(image_path, "wb") as f:
f.write(await file.read())
image_asset = ImageAsset(path=image_path, is_uploaded=True)
sql_session.add(image_asset)
await sql_session.commit()
return image_asset
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to upload image: {str(e)}")
@IMAGES_ROUTER.get("/uploaded", response_model=List[ImageAsset])
async def get_uploaded_images(sql_session: AsyncSession = Depends(get_async_session)):
try:
images = await sql_session.scalars(
select(ImageAsset)
.where(ImageAsset.is_uploaded == True)
.order_by(ImageAsset.created_at.desc())
)
return images
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to retrieve uploaded images: {str(e)}"
)
@IMAGES_ROUTER.delete("/{id}", status_code=204)
async def delete_uploaded_image_by_id(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
try:
# Fetch the asset to get its actual file path
image = await sql_session.get(ImageAsset, id)
if not image:
raise HTTPException(status_code=404, detail="Image not found")
os.remove(image.path)
await sql_session.delete(image)
await sql_session.commit()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete image: {str(e)}")

View file

@ -0,0 +1,27 @@
from fastapi import APIRouter, HTTPException
import aiohttp
from typing import List, Any
from utils.get_layout_by_name import get_layout_by_name
from models.presentation_layout import PresentationLayoutModel
LAYOUTS_ROUTER = APIRouter(prefix="/layouts", tags=["Layouts"])
@LAYOUTS_ROUTER.get("/", summary="Get available layouts")
async def get_layouts():
url = "http://localhost:3000/api/layouts" # Adjust port if needed
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status != 200:
error_text = await response.text()
raise HTTPException(
status_code=response.status,
detail=f"Failed to fetch layouts: {error_text}"
)
layouts_json = await response.json()
# Optionally, parse into a Pydantic model if you have one matching the structure
return layouts_json
@LAYOUTS_ROUTER.get("/{layout_name}", summary="Get layout details by ID")
async def get_layout_detail(layout_name: str) -> PresentationLayoutModel:
return await get_layout_by_name(layout_name)

View file

@ -0,0 +1,85 @@
from datetime import datetime, timedelta
import json
from typing import List
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from api.v1.ppt.background_tasks import pull_ollama_model_background_task
from constants.supported_ollama_models import SUPPORTED_OLLAMA_MODELS
from models.ollama_model_metadata import OllamaModelMetadata
from models.ollama_model_status import OllamaModelStatus
from models.sql.ollama_pull_status import OllamaPullStatus
from services.database import get_async_session
from utils.ollama import list_pulled_ollama_models
OLLAMA_ROUTER = APIRouter(prefix="/ollama", tags=["Ollama"])
@OLLAMA_ROUTER.get("/models/supported", response_model=List[OllamaModelMetadata])
def get_supported_models():
return SUPPORTED_OLLAMA_MODELS.values()
@OLLAMA_ROUTER.get("/models/available", response_model=List[OllamaModelStatus])
async def get_available_models():
return await list_pulled_ollama_models()
@OLLAMA_ROUTER.get("/model/pull", response_model=OllamaModelStatus)
async def pull_model(
model: str,
background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_async_session),
):
if model not in SUPPORTED_OLLAMA_MODELS:
raise HTTPException(
status_code=400,
detail=f"Model {model} is not supported",
)
try:
pulled_models = await list_pulled_ollama_models()
filtered_models = [
pulled_model for pulled_model in pulled_models if pulled_model.name == model
]
if filtered_models:
return filtered_models[0]
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to check pulled models: {e}",
)
saved_pull_status = None
saved_model_status = None
try:
saved_pull_status = await session.get(OllamaPullStatus, model)
saved_model_status = saved_pull_status.status
except Exception as e:
pass
# If the model is being pulled, return the model
if saved_model_status:
# If the model is being pulled, return the model
# ? If the model status is pulled in database but was not found while listing pulled models,
# ? it means the model was deleted and we need to pull it again
if (
saved_model_status["status"] == "error"
or saved_model_status["status"] == "pulled"
or saved_pull_status.last_updated < (datetime.now() - timedelta(seconds=10))
):
await session.delete(saved_pull_status)
else:
return saved_model_status
# If the model is not being pulled, pull the model
background_tasks.add_task(pull_ollama_model_background_task, model)
return OllamaModelStatus(
name=model,
status="pulling",
done=False,
)

View file

@ -0,0 +1,17 @@
from typing import Annotated, List
from fastapi import APIRouter, Body, HTTPException
from utils.available_models import list_available_openai_compatible_models
OPENAI_ROUTER = APIRouter(prefix="/openai", tags=["OpenAI"])
@OPENAI_ROUTER.post("/models/available", response_model=List[str])
async def get_available_models(
url: Annotated[str, Body()],
api_key: Annotated[str, Body()],
):
try:
return await list_available_openai_compatible_models(url, api_key)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,113 @@
import asyncio
import json
import math
import traceback
import uuid
import dirtyjson
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from models.presentation_outline_model import PresentationOutlineModel
from models.sql.presentation import PresentationModel
from models.sse_response import (
SSECompleteResponse,
SSEErrorResponse,
SSEResponse,
SSEStatusResponse,
)
from services.temp_file_service import TEMP_FILE_SERVICE
from services.database import get_async_session
from services.documents_loader import DocumentsLoader
from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline
from utils.ppt_utils import get_presentation_title_from_outlines
OUTLINES_ROUTER = APIRouter(prefix="/outlines", tags=["Outlines"])
@OUTLINES_ROUTER.get("/stream/{id}")
async def stream_outlines(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
async def inner():
yield SSEStatusResponse(
status="Generating presentation outlines..."
).to_string()
additional_context = ""
if presentation.file_paths:
documents_loader = DocumentsLoader(file_paths=presentation.file_paths)
await documents_loader.load_documents(temp_dir)
documents = documents_loader.documents
if documents:
additional_context = "\n\n".join(documents)
presentation_outlines_text = ""
n_slides_to_generate = presentation.n_slides
if presentation.include_table_of_contents:
needed_toc_count = math.ceil((presentation.n_slides - 1) / 10)
n_slides_to_generate -= math.ceil(
(presentation.n_slides - needed_toc_count) / 10
)
async for chunk in generate_ppt_outline(
presentation.content,
n_slides_to_generate,
presentation.language,
additional_context,
presentation.tone,
presentation.verbosity,
presentation.instructions,
presentation.include_title_slide,
presentation.web_search,
):
# Give control to the event loop
await asyncio.sleep(0)
if isinstance(chunk, HTTPException):
yield SSEErrorResponse(detail=chunk.detail).to_string()
return
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": chunk}),
).to_string()
presentation_outlines_text += chunk
try:
presentation_outlines_json = dict(
dirtyjson.loads(presentation_outlines_text)
)
except Exception as e:
traceback.print_exc()
yield SSEErrorResponse(
detail=f"Failed to generate presentation outlines. Please try again. {str(e)}",
).to_string()
return
presentation_outlines = PresentationOutlineModel(**presentation_outlines_json)
presentation_outlines.slides = presentation_outlines.slides[
:n_slides_to_generate
]
presentation.outlines = presentation_outlines.model_dump()
presentation.title = get_presentation_title_from_outlines(presentation_outlines)
sql_session.add(presentation)
await sql_session.commit()
yield SSECompleteResponse(
key="presentation", value=presentation.model_dump(mode="json")
).to_string()
return StreamingResponse(inner(), media_type="text/event-stream")

View file

@ -0,0 +1,116 @@
import os
import shutil
import tempfile
import subprocess
from typing import List, Optional
from fastapi import APIRouter, UploadFile, File, HTTPException
from pydantic import BaseModel
from services.documents_loader import DocumentsLoader
from utils.asset_directory_utils import get_images_directory
import uuid
from constants.documents import PDF_MIME_TYPES
PDF_SLIDES_ROUTER = APIRouter(prefix="/pdf-slides", tags=["PDF Slides"])
class PdfSlideData(BaseModel):
slide_number: int
screenshot_url: str
class PdfSlidesResponse(BaseModel):
success: bool
slides: List[PdfSlideData]
total_slides: int
@PDF_SLIDES_ROUTER.post("/process", response_model=PdfSlidesResponse)
async def process_pdf_slides(
pdf_file: UploadFile = File(..., description="PDF file to process")
):
"""
Process a PDF file to extract slide screenshots.
This endpoint:
1. Validates the uploaded PDF file
2. Uses ImageMagick to convert PDF pages to PNG images
3. Returns screenshot URLs for each slide/page
Note: Font installation is not needed since PDFs already have fonts embedded.
"""
# Validate PDF file
if pdf_file.content_type not in PDF_MIME_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Expected PDF file, got {pdf_file.content_type}",
)
# Enforce 100MB size limit
if (
hasattr(pdf_file, "size")
and pdf_file.size
and pdf_file.size > (100 * 1024 * 1024)
):
raise HTTPException(
status_code=400,
detail="PDF file exceeded max upload size of 100 MB",
)
# Create temporary directory for processing
with tempfile.TemporaryDirectory() as temp_dir:
try:
# Save uploaded PDF file
pdf_path = os.path.join(temp_dir, "presentation.pdf")
with open(pdf_path, "wb") as f:
pdf_content = await pdf_file.read()
f.write(pdf_content)
# Generate screenshots from PDF using ImageMagick
screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async(
pdf_path, temp_dir
)
print(f"Generated {len(screenshot_paths)} PDF screenshots")
# Move screenshots to images directory and generate URLs
images_dir = get_images_directory()
presentation_id = uuid.uuid4()
presentation_images_dir = os.path.join(images_dir, str(presentation_id))
os.makedirs(presentation_images_dir, exist_ok=True)
slides_data = []
for i, screenshot_path in enumerate(screenshot_paths, 1):
# Move screenshot to permanent location
screenshot_filename = f"slide_{i}.png"
permanent_screenshot_path = os.path.join(
presentation_images_dir, screenshot_filename
)
if (
os.path.exists(screenshot_path)
and os.path.getsize(screenshot_path) > 0
):
# Use shutil.copy2 instead of os.rename to handle cross-device moves
shutil.copy2(screenshot_path, permanent_screenshot_path)
screenshot_url = (
f"/app_data/images/{presentation_id}/{screenshot_filename}"
)
else:
# Fallback if screenshot generation failed or file is empty placeholder
screenshot_url = "/static/images/placeholder.jpg"
slides_data.append(
PdfSlideData(slide_number=i, screenshot_url=screenshot_url)
)
return PdfSlidesResponse(
success=True, slides=slides_data, total_slides=len(slides_data)
)
except Exception as e:
print(f"Error processing PDF slides: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Failed to process PDF: {str(e)}"
)

View file

@ -0,0 +1,613 @@
import os
import shutil
import zipfile
import tempfile
import subprocess
import uuid
from typing import List, Optional, Dict
from fastapi import APIRouter, UploadFile, File, HTTPException
from pydantic import BaseModel
import aiohttp
import asyncio
import xml.etree.ElementTree as ET
import re
from services.documents_loader import DocumentsLoader
from utils.asset_directory_utils import get_images_directory
import uuid
from constants.documents import POWERPOINT_TYPES
PPTX_SLIDES_ROUTER = APIRouter(prefix="/pptx-slides", tags=["PPTX Slides"])
class SlideData(BaseModel):
slide_number: int
screenshot_url: str
xml_content: str
normalized_fonts: List[str]
class FontAnalysisResult(BaseModel):
internally_supported_fonts: List[
Dict[str, str]
] # [{"name": "Open Sans", "google_fonts_url": "..."}]
not_supported_fonts: List[str] # ["Custom Font Name"]
class PptxSlidesResponse(BaseModel):
success: bool
slides: List[SlideData]
total_slides: int
fonts: Optional[FontAnalysisResult] = None
# NEW: Fonts-only router and response for PPTX
class PptxFontsResponse(BaseModel):
success: bool
fonts: FontAnalysisResult
PPTX_FONTS_ROUTER = APIRouter(prefix="/pptx-fonts", tags=["PPTX Fonts"])
# NEW: Normalize font family names by removing style/weight/stretch descriptors and splitting camel case
_STYLE_TOKENS = {
# styles
"italic",
"italics",
"ital",
"oblique",
"roman",
# combined style shortcuts
"bolditalic",
"bolditalics",
# weights
"thin",
"hairline",
"extralight",
"ultralight",
"light",
"demilight",
"semilight",
"book",
"regular",
"normal",
"medium",
"semibold",
"demibold",
"bold",
"extrabold",
"ultrabold",
"black",
"extrablack",
"ultrablack",
"heavy",
# width/stretch
"narrow",
"condensed",
"semicondensed",
"extracondensed",
"ultracondensed",
"expanded",
"semiexpanded",
"extraexpanded",
"ultraexpanded",
}
# Modifiers commonly used with style tokens
_STYLE_MODIFIERS = {"semi", "demi", "extra", "ultra"}
def _insert_spaces_in_camel_case(value: str) -> str:
# Insert space before capital letters preceded by lowercase or digits (e.g., MontserratBold -> Montserrat Bold)
value = re.sub(r"(?<=[a-z0-9])([A-Z])", r" \1", value)
# Handle sequences like BoldItalic -> Bold Italic
value = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", value)
return value
def normalize_font_family_name(raw_name: str) -> str:
if not raw_name:
return raw_name
# Replace separators with spaces
name = raw_name.replace("_", " ").replace("-", " ")
# Insert spaces in camel case
name = _insert_spaces_in_camel_case(name)
# Collapse multiple spaces
name = re.sub(r"\s+", " ", name).strip()
# Lowercase helper for matching but keep original casing for output
lower_name = name.lower()
# Quick cut: if the full string ends with a pure style suffix, trim it
for style in sorted(_STYLE_TOKENS, key=len, reverse=True):
if lower_name.endswith(" " + style):
name = name[: -(len(style) + 1)]
lower_name = lower_name[: -(len(style) + 1)]
break
# Tokenize
tokens_original = name.split(" ")
tokens_filtered: List[str] = []
for index, tok in enumerate(tokens_original):
lower_tok = tok.lower()
# Always keep the first token to avoid stripping families like "Black Ops One"
if index == 0:
tokens_filtered.append(tok)
continue
# Drop style tokens and standalone modifiers
if lower_tok in _STYLE_TOKENS or lower_tok in _STYLE_MODIFIERS:
continue
tokens_filtered.append(tok)
# If everything except first token was dropped and first token is a style token (unlikely), fallback to original
if not tokens_filtered:
tokens_filtered = tokens_original
normalized = " ".join(tokens_filtered).strip()
# Final cleanup of leftover multiple spaces
normalized = re.sub(r"\s+", " ", normalized)
return normalized
def extract_fonts_from_oxml(xml_content: str) -> List[str]:
"""
Extract font names from OXML content.
Args:
xml_content: OXML content as string
Returns:
List of unique font names found in the OXML
"""
fonts = set()
try:
# Parse the XML content
root = ET.fromstring(xml_content)
# Define namespaces commonly used in OXML
namespaces = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
# Search for font references in various OXML elements
# Look for latin fonts
for font_elem in root.findall(".//a:latin", namespaces):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Look for east asian fonts
for font_elem in root.findall(".//a:ea", namespaces):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Look for complex script fonts
for font_elem in root.findall(".//a:cs", namespaces):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Look for font references in theme elements
for font_elem in root.findall(".//a:font", namespaces):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Look for rPr (run properties) font references
for rpr_elem in root.findall(".//a:rPr", namespaces):
for font_elem in rpr_elem.findall(".//a:latin", namespaces):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Also search without namespace prefix for compatibility
for font_elem in root.findall(".//latin"):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Regex fallback for fonts that might be missed
font_pattern = r'typeface="([^"]+)"'
regex_fonts = re.findall(font_pattern, xml_content)
fonts.update(regex_fonts)
# Filter out system fonts and empty values
system_fonts = {"+mn-lt", "+mj-lt", "+mn-ea", "+mj-ea", "+mn-cs", "+mj-cs", ""}
fonts = {font for font in fonts if font not in system_fonts and font.strip()}
return list(fonts)
except Exception as e:
print(f"Error extracting fonts from OXML: {e}")
return []
async def check_google_font_availability(font_name: str) -> bool:
"""
Check if a font is available in Google Fonts.
Args:
font_name: Name of the font to check
Returns:
True if font is available in Google Fonts, False otherwise
"""
try:
formatted_name = font_name.replace(" ", "+")
url = f"https://fonts.googleapis.com/css2?family={formatted_name}&display=swap"
async with aiohttp.ClientSession() as session:
async with session.head(
url, timeout=aiohttp.ClientTimeout(total=10)
) as response:
return response.status == 200
except Exception as e:
print(f"Error checking Google Font availability for {font_name}: {e}")
return False
async def analyze_fonts_in_all_slides(slide_xmls: List[str]) -> FontAnalysisResult:
"""
Analyze fonts across all slides and determine Google Fonts availability.
Args:
slide_xmls: List of OXML content strings from all slides
Returns:
FontAnalysisResult with supported and unsupported fonts
"""
# Extract fonts from all slides
raw_fonts = set()
for xml_content in slide_xmls:
slide_fonts = extract_fonts_from_oxml(xml_content)
raw_fonts.update(slide_fonts)
# Normalize to root families (e.g., "Montserrat Italic" -> "Montserrat")
normalized_fonts = {normalize_font_family_name(f) for f in raw_fonts}
# Remove empties if any
normalized_fonts = {f for f in normalized_fonts if f}
if not normalized_fonts:
return FontAnalysisResult(internally_supported_fonts=[], not_supported_fonts=[])
# Check each normalized font's availability in Google Fonts concurrently
tasks = [check_google_font_availability(font) for font in normalized_fonts]
results = await asyncio.gather(*tasks)
internally_supported_fonts = []
not_supported_fonts = []
for font, is_available in zip(normalized_fonts, results):
if is_available:
formatted_name = font.replace(" ", "+")
google_fonts_url = f"https://fonts.googleapis.com/css2?family={formatted_name}&display=swap"
internally_supported_fonts.append(
{"name": font, "google_fonts_url": google_fonts_url}
)
else:
not_supported_fonts.append(font)
return FontAnalysisResult(
internally_supported_fonts=internally_supported_fonts, not_supported_fonts=[]
)
@PPTX_SLIDES_ROUTER.post("/process", response_model=PptxSlidesResponse)
async def process_pptx_slides(
pptx_file: UploadFile = File(..., description="PPTX file to process"),
fonts: Optional[List[UploadFile]] = File(None, description="Optional font files"),
):
"""
Process a PPTX file to extract slide screenshots and XML content.
This endpoint:
1. Validates the uploaded PPTX file
2. Installs any provided font files
3. Unzips the PPTX to extract slide XMLs
4. Uses LibreOffice to generate slide screenshots
5. Returns both screenshot URLs and XML content for each slide
"""
# Validate PPTX file
if pptx_file.content_type not in POWERPOINT_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}",
)
# Enforce 100MB size limit
if (
hasattr(pptx_file, "size")
and pptx_file.size
and pptx_file.size > (100 * 1024 * 1024)
):
raise HTTPException(
status_code=400,
detail="PPTX file exceeded max upload size of 100 MB",
)
# Create temporary directory for processing
with tempfile.TemporaryDirectory() as temp_dir:
if True:
# Save uploaded PPTX file
pptx_path = os.path.join(temp_dir, "presentation.pptx")
with open(pptx_path, "wb") as f:
pptx_content = await pptx_file.read()
f.write(pptx_content)
# Install fonts if provided
if fonts:
await _install_fonts(fonts, temp_dir)
# Extract slide XMLs from PPTX
slide_xmls = _extract_slide_xmls(pptx_path, temp_dir)
# Convert PPTX to PDF
pdf_path = await _convert_pptx_to_pdf(pptx_path, temp_dir)
# Generate screenshots using LibreOffice
screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async(
pdf_path, temp_dir
)
print(f"Screenshot paths: {screenshot_paths}")
# Analyze fonts across all slides
font_analysis = await analyze_fonts_in_all_slides(slide_xmls)
print(
f"Font analysis completed: {len(font_analysis.internally_supported_fonts)} supported, {len(font_analysis.not_supported_fonts)} not supported"
)
# Move screenshots to images directory and generate URLs
images_dir = get_images_directory()
presentation_id = uuid.uuid4()
presentation_images_dir = os.path.join(images_dir, str(presentation_id))
os.makedirs(presentation_images_dir, exist_ok=True)
slides_data = []
for i, (xml_content, screenshot_path) in enumerate(
zip(slide_xmls, screenshot_paths), 1
):
# Move screenshot to permanent location
screenshot_filename = f"slide_{i}.png"
permanent_screenshot_path = os.path.join(
presentation_images_dir, screenshot_filename
)
if (
os.path.exists(screenshot_path)
and os.path.getsize(screenshot_path) > 0
):
# Use shutil.copy2 instead of os.rename to handle cross-device moves
shutil.copy2(screenshot_path, permanent_screenshot_path)
screenshot_url = (
f"/app_data/images/{presentation_id}/{screenshot_filename}"
)
else:
# Fallback if screenshot generation failed or file is empty placeholder
screenshot_url = "/static/images/placeholder.jpg"
# Compute normalized fonts for this slide
raw_slide_fonts = extract_fonts_from_oxml(xml_content)
normalized_fonts = sorted(
{normalize_font_family_name(f) for f in raw_slide_fonts if f}
)
slides_data.append(
SlideData(
slide_number=i,
screenshot_url=screenshot_url,
xml_content=xml_content,
normalized_fonts=normalized_fonts,
)
)
return PptxSlidesResponse(
success=True,
slides=slides_data,
total_slides=len(slides_data),
fonts=font_analysis,
)
# NEW: Fonts-only endpoint leveraging the same font extraction/analysis
@PPTX_FONTS_ROUTER.post("/process", response_model=PptxFontsResponse)
async def process_pptx_fonts(
pptx_file: UploadFile = File(..., description="PPTX file to analyze fonts from")
):
"""
Analyze a PPTX file and return only the fonts used in the document.
Uses the exact same font extraction and analysis utilities as the /pptx-slides endpoint.
"""
# Validate PPTX file
if pptx_file.content_type not in POWERPOINT_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}",
)
# Create temporary directory for processing
with tempfile.TemporaryDirectory() as temp_dir:
# Save uploaded PPTX file
pptx_path = os.path.join(temp_dir, "presentation.pptx")
with open(pptx_path, "wb") as f:
pptx_content = await pptx_file.read()
f.write(pptx_content)
# Extract slide XMLs from PPTX
slide_xmls = _extract_slide_xmls(pptx_path, temp_dir)
# Analyze fonts across all slides (same logic as in /pptx-slides)
font_analysis = await analyze_fonts_in_all_slides(slide_xmls)
return PptxFontsResponse(
success=True,
fonts=font_analysis,
)
def _create_font_alias_config(raw_fonts: List[str]) -> str:
"""Create a temporary fontconfig configuration that aliases variant family names to normalized root families.
Returns the path to the config file.
"""
# Build mapping from raw -> normalized where different
mappings: Dict[str, str] = {}
for f in raw_fonts:
normalized = normalize_font_family_name(f)
if normalized and normalized != f:
mappings[f] = normalized
# Create config only if we have mappings
fd, fonts_conf_path = tempfile.mkstemp(prefix="fonts_alias_", suffix=".conf")
os.close(fd)
with open(fonts_conf_path, "w", encoding="utf-8") as cfg:
cfg.write(
"""<?xml version='1.0'?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<include>/etc/fonts/fonts.conf</include>
"""
)
for src, dst in mappings.items():
cfg.write(
f"""
<match target="pattern">
<test name="family" compare="eq">
<string>{src}</string>
</test>
<edit name="family" mode="assign" binding="strong">
<string>{dst}</string>
</edit>
</match>
"""
)
cfg.write("\n</fontconfig>\n")
return fonts_conf_path
async def _install_fonts(fonts: List[UploadFile], temp_dir: str) -> None:
"""Install provided font files to the system."""
fonts_dir = os.path.join(temp_dir, "fonts")
os.makedirs(fonts_dir, exist_ok=True)
for font_file in fonts:
# Save font file
font_path = os.path.join(fonts_dir, font_file.filename)
with open(font_path, "wb") as f:
font_content = await font_file.read()
f.write(font_content)
# Install font (copy to system fonts directory)
try:
subprocess.run(
["cp", font_path, "/usr/share/fonts/truetype/"],
check=True,
capture_output=True,
)
except subprocess.CalledProcessError as e:
print(f"Warning: Failed to install font {font_file.filename}: {e}")
# Refresh font cache
try:
subprocess.run(["fc-cache", "-f", "-v"], check=True, capture_output=True)
except subprocess.CalledProcessError as e:
print(f"Warning: Failed to refresh font cache: {e}")
def _extract_slide_xmls(pptx_path: str, temp_dir: str) -> List[str]:
"""Extract slide XML content from PPTX file."""
slide_xmls = []
extract_dir = os.path.join(temp_dir, "pptx_extract")
try:
# Unzip PPTX file
with zipfile.ZipFile(pptx_path, "r") as zip_ref:
zip_ref.extractall(extract_dir)
# Look for slides in ppt/slides/ directory
slides_dir = os.path.join(extract_dir, "ppt", "slides")
if not os.path.exists(slides_dir):
raise Exception("No slides directory found in PPTX file")
# Get all slide XML files and sort them numerically
slide_files = [
f
for f in os.listdir(slides_dir)
if f.startswith("slide") and f.endswith(".xml")
]
slide_files.sort(key=lambda x: int(x.replace("slide", "").replace(".xml", "")))
# Read XML content from each slide
for slide_file in slide_files:
slide_path = os.path.join(slides_dir, slide_file)
with open(slide_path, "r", encoding="utf-8") as f:
slide_xmls.append(f.read())
return slide_xmls
except Exception as e:
raise Exception(f"Failed to extract slide XMLs: {str(e)}")
async def _convert_pptx_to_pdf(pptx_path: str, temp_dir: str) -> str:
"""Generate PNG screenshots of PPTX slides using LibreOffice + ImageMagick."""
screenshots_dir = os.path.join(temp_dir, "screenshots")
os.makedirs(screenshots_dir, exist_ok=True)
try:
# First, get the number of slides by extracting XMLs
slide_xmls = _extract_slide_xmls(pptx_path, temp_dir)
slide_count = len(slide_xmls)
# Build font alias config to force variant families to resolve to normalized root families
raw_fonts: List[str] = []
for xml in slide_xmls:
raw_fonts.extend(extract_fonts_from_oxml(xml))
raw_fonts = list({f for f in raw_fonts if f})
fonts_conf_path = _create_font_alias_config(raw_fonts)
env = os.environ.copy()
env["FONTCONFIG_FILE"] = fonts_conf_path
print(f"Found {slide_count} slides in presentation")
# Step 1: Convert PPTX to PDF using LibreOffice
print("Starting LibreOffice PDF conversion...")
pdf_filename = "temp_presentation.pdf"
pdf_path = os.path.join(screenshots_dir, pdf_filename)
try:
result = subprocess.run(
[
"libreoffice",
"--headless",
"--convert-to",
"pdf",
"--outdir",
screenshots_dir,
pptx_path,
],
check=True,
capture_output=True,
text=True,
timeout=500,
env=env,
)
print(f"LibreOffice PDF conversion output: {result.stdout}")
if result.stderr:
print(f"LibreOffice PDF conversion warnings: {result.stderr}")
except subprocess.TimeoutExpired:
raise Exception("LibreOffice PDF conversion timed out after 120 seconds")
except subprocess.CalledProcessError as e:
error_msg = e.stderr if e.stderr else str(e)
raise Exception(f"LibreOffice PDF conversion failed: {error_msg}")
# Find the generated PDF file (LibreOffice uses original filename)
pdf_files = [f for f in os.listdir(screenshots_dir) if f.endswith(".pdf")]
if not pdf_files:
raise Exception("LibreOffice failed to generate PDF file")
actual_pdf_path = os.path.join(screenshots_dir, pdf_files[0])
print(f"Generated PDF: {actual_pdf_path}")
return actual_pdf_path
except Exception as e:
# Re-raise the specific exceptions we've already handled
if "timed out" in str(e) or "failed:" in str(e):
raise
# Handle any other unexpected exceptions
raise Exception(f"Screenshot generation failed: {str(e)}")

View file

@ -0,0 +1,973 @@
import asyncio
from datetime import datetime
import json
import math
import os
import random
import traceback
from typing import Annotated, List, Literal, Optional, Tuple
import dirtyjson
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path
from fastapi.responses import StreamingResponse
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from constants.presentation import DEFAULT_TEMPLATES
from enums.webhook_event import WebhookEvent
from models.api_error_model import APIErrorModel
from models.generate_presentation_request import GeneratePresentationRequest
from models.presentation_and_path import PresentationPathAndEditPath
from models.presentation_from_template import EditPresentationRequest
from models.presentation_outline_model import (
PresentationOutlineModel,
SlideOutlineModel,
)
from enums.tone import Tone
from enums.verbosity import Verbosity
from models.pptx_models import PptxPresentationModel
from models.presentation_layout import PresentationLayoutModel
from models.presentation_structure_model import PresentationStructureModel
from models.presentation_with_slides import (
PresentationWithSlides,
)
from models.sql.template import TemplateModel
from services.documents_loader import DocumentsLoader
from services.webhook_service import WebhookService
from utils.get_layout_by_name import get_layout_by_name
from services.image_generation_service import ImageGenerationService
from utils.dict_utils import deep_update
from utils.export_utils import export_presentation
from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline
from models.sql.slide import SlideModel
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
from services.database import get_async_session
from services.temp_file_service import TEMP_FILE_SERVICE
from services.concurrent_service import CONCURRENT_SERVICE
from models.sql.presentation import PresentationModel
from models.sql.user import UserModel
from utils.auth_dependencies import get_current_user
from services.pptx_presentation_creator import PptxPresentationCreator
from models.sql.async_presentation_generation_status import (
AsyncPresentationGenerationTaskModel,
)
from utils.asset_directory_utils import get_exports_directory, get_images_directory
from utils.llm_calls.generate_presentation_structure import (
generate_presentation_structure,
)
from utils.llm_calls.generate_slide_content import (
get_slide_content_from_type_and_outline,
)
from utils.ppt_utils import (
get_presentation_title_from_outlines,
select_toc_or_list_slide_layout_index,
)
from utils.process_slides import (
process_slide_add_placeholder_assets,
process_slide_and_fetch_assets,
)
import uuid
PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"])
@PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides])
async def get_all_presentations(
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
presentations_with_slides = []
query = (
select(PresentationModel, SlideModel)
.join(
SlideModel,
(SlideModel.presentation == PresentationModel.id) & (SlideModel.index == 0),
)
.order_by(PresentationModel.created_at.desc())
)
results = await sql_session.execute(query)
rows = results.all()
presentations_with_slides = [
PresentationWithSlides(
**presentation.model_dump(),
slides=[first_slide],
)
for presentation, first_slide in rows
]
return presentations_with_slides
@PRESENTATION_ROUTER.get("/{id}", response_model=PresentationWithSlides)
async def get_presentation(
id: uuid.UUID,
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(404, "Presentation not found")
slides = await sql_session.scalars(
select(SlideModel)
.where(SlideModel.presentation == id)
.order_by(SlideModel.index)
)
return PresentationWithSlides(
**presentation.model_dump(),
slides=slides,
)
@PRESENTATION_ROUTER.delete("/{id}", status_code=204)
async def delete_presentation(
id: uuid.UUID,
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(404, "Presentation not found")
await sql_session.delete(presentation)
await sql_session.commit()
@PRESENTATION_ROUTER.post("/create", response_model=PresentationModel)
async def create_presentation(
content: Annotated[str, Body()],
n_slides: Annotated[int, Body()],
language: Annotated[str, Body()],
file_paths: Annotated[Optional[List[str]], Body()] = None,
tone: Annotated[Tone, Body()] = Tone.DEFAULT,
verbosity: Annotated[Verbosity, Body()] = Verbosity.STANDARD,
instructions: Annotated[Optional[str], Body()] = None,
include_table_of_contents: Annotated[bool, Body()] = False,
include_title_slide: Annotated[bool, Body()] = True,
web_search: Annotated[bool, Body()] = False,
current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
if include_table_of_contents and n_slides < 3:
raise HTTPException(
status_code=400,
detail="Number of slides cannot be less than 3 if table of contents is included",
)
presentation_id = uuid.uuid4()
presentation = PresentationModel(
id=presentation_id,
content=content,
n_slides=n_slides,
language=language,
file_paths=file_paths,
tone=tone.value,
verbosity=verbosity.value,
instructions=instructions,
include_table_of_contents=include_table_of_contents,
include_title_slide=include_title_slide,
web_search=web_search,
owner_id=current_user.id,
)
sql_session.add(presentation)
await sql_session.commit()
return presentation
@PRESENTATION_ROUTER.post("/prepare", response_model=PresentationModel)
async def prepare_presentation(
presentation_id: Annotated[uuid.UUID, Body()],
outlines: Annotated[List[SlideOutlineModel], Body()],
layout: Annotated[PresentationLayoutModel, Body()],
title: Annotated[Optional[str], Body()] = None,
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
if not outlines:
raise HTTPException(status_code=400, detail="Outlines are required")
presentation = await sql_session.get(PresentationModel, presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_outline_model = PresentationOutlineModel(slides=outlines)
total_slide_layouts = len(layout.slides)
total_outlines = len(outlines)
if layout.ordered:
presentation_structure = layout.to_presentation_structure()
else:
presentation_structure: PresentationStructureModel = (
await generate_presentation_structure(
presentation_outline=presentation_outline_model,
presentation_layout=layout,
instructions=presentation.instructions,
)
)
presentation_structure.slides = presentation_structure.slides[: len(outlines)]
for index in range(total_outlines):
random_slide_index = random.randint(0, total_slide_layouts - 1)
if index >= total_outlines:
presentation_structure.slides.append(random_slide_index)
continue
if presentation_structure.slides[index] >= total_slide_layouts:
presentation_structure.slides[index] = random_slide_index
if presentation.include_table_of_contents:
n_toc_slides = presentation.n_slides - total_outlines
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout)
if toc_slide_layout_index != -1:
outline_index = 1 if presentation.include_title_slide else 0
for i in range(n_toc_slides):
outlines_to = outline_index + 10
if total_outlines == outlines_to:
outlines_to -= 1
presentation_structure.slides.insert(
i + 1 if presentation.include_title_slide else i,
toc_slide_layout_index,
)
toc_outline = "Table of Contents\n\n"
for outline in presentation_outline_model.slides[
outline_index:outlines_to
]:
page_number = (
outline_index - i + n_toc_slides + 1
if presentation.include_title_slide
else outline_index - i + n_toc_slides
)
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
outline_index += 1
outline_index += 1
presentation_outline_model.slides.insert(
i + 1 if presentation.include_title_slide else i,
SlideOutlineModel(
content=toc_outline,
),
)
sql_session.add(presentation)
presentation.outlines = presentation_outline_model.model_dump(mode="json")
presentation.title = title or presentation.title
presentation.set_layout(layout)
presentation.set_structure(presentation_structure)
await sql_session.commit()
return presentation
@PRESENTATION_ROUTER.get("/stream/{id}", response_model=PresentationWithSlides)
async def stream_presentation(
id: uuid.UUID,
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
if not presentation.structure:
raise HTTPException(
status_code=400,
detail="Presentation not prepared for stream",
)
if not presentation.outlines:
raise HTTPException(
status_code=400,
detail="Outlines can not be empty",
)
image_generation_service = ImageGenerationService(get_images_directory())
async def inner():
structure = presentation.get_structure()
layout = presentation.get_layout()
outline = presentation.get_presentation_outline()
# These tasks will be gathered and awaited after all slides are generated
async_assets_generation_tasks = []
slides: List[SlideModel] = []
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": '{ "slides": [ '}),
).to_string()
for i, slide_layout_index in enumerate(structure.slides):
slide_layout = layout.slides[slide_layout_index]
try:
slide_content = await get_slide_content_from_type_and_outline(
slide_layout,
outline.slides[i],
presentation.language,
presentation.tone,
presentation.verbosity,
presentation.instructions,
)
except HTTPException as e:
yield SSEErrorResponse(detail=e.detail).to_string()
return
slide = SlideModel(
presentation=id,
layout_group=layout.name,
layout=slide_layout.id,
index=i,
speaker_note=slide_content.get("__speaker_note__", ""),
content=slide_content,
)
slides.append(slide)
# This will mutate slide and add placeholder assets
process_slide_add_placeholder_assets(slide)
# This will mutate slide
async_assets_generation_tasks.append(
process_slide_and_fetch_assets(image_generation_service, slide)
)
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": slide.model_dump_json()}),
).to_string()
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": " ] }"}),
).to_string()
generated_assets_lists = await asyncio.gather(*async_assets_generation_tasks)
generated_assets = []
for assets_list in generated_assets_lists:
generated_assets.extend(assets_list)
# Moved this here to make sure new slides are generated before deleting the old ones
await sql_session.execute(
delete(SlideModel).where(SlideModel.presentation == id)
)
await sql_session.commit()
sql_session.add(presentation)
sql_session.add_all(slides)
sql_session.add_all(generated_assets)
await sql_session.commit()
response = PresentationWithSlides(
**presentation.model_dump(),
slides=slides,
)
yield SSECompleteResponse(
key="presentation",
value=response.model_dump(mode="json"),
).to_string()
return StreamingResponse(inner(), media_type="text/event-stream")
@PRESENTATION_ROUTER.patch("/update", response_model=PresentationWithSlides)
async def update_presentation(
id: Annotated[uuid.UUID, Body()],
n_slides: Annotated[Optional[int], Body()] = None,
title: Annotated[Optional[str], Body()] = None,
slides: Annotated[Optional[List[SlideModel]], Body()] = None,
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_update_dict = {}
if n_slides:
presentation_update_dict["n_slides"] = n_slides
if title:
presentation_update_dict["title"] = title
if n_slides or title:
presentation.sqlmodel_update(presentation_update_dict)
if slides:
# Just to make sure id is UUID
for slide in slides:
slide.presentation = uuid.UUID(slide.presentation)
slide.id = uuid.UUID(slide.id)
await sql_session.execute(
delete(SlideModel).where(SlideModel.presentation == presentation.id)
)
sql_session.add_all(slides)
await sql_session.commit()
return PresentationWithSlides(
**presentation.model_dump(),
slides=slides or [],
)
@PRESENTATION_ROUTER.post("/export/pptx", response_model=str)
async def export_presentation_as_pptx(
pptx_model: Annotated[PptxPresentationModel, Body()],
_current_user: UserModel = Depends(get_current_user),
):
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
await pptx_creator.create_ppt()
export_directory = get_exports_directory()
pptx_path = os.path.join(
export_directory, f"{pptx_model.name or uuid.uuid4()}.pptx"
)
pptx_creator.save(pptx_path)
return pptx_path
@PRESENTATION_ROUTER.post("/export", response_model=PresentationPathAndEditPath)
async def export_presentation_as_pptx_or_pdf(
id: Annotated[uuid.UUID, Body(description="Presentation ID to export")],
export_as: Annotated[
Literal["pptx", "pdf"], Body(description="Format to export the presentation as")
] = "pptx",
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_and_path = await export_presentation(
id,
presentation.title or str(uuid.uuid4()),
export_as,
)
return PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={id}",
)
async def check_if_api_request_is_valid(
request: GeneratePresentationRequest,
sql_session: AsyncSession = Depends(get_async_session),
) -> Tuple[uuid.UUID,]:
presentation_id = uuid.uuid4()
print(f"Presentation ID: {presentation_id}")
# Making sure either content, slides markdown or files is provided
if not (request.content or request.slides_markdown or request.files):
raise HTTPException(
status_code=400,
detail="Either content or slides markdown or files is required to generate presentation",
)
# Making sure number of slides is greater than 0
if request.n_slides <= 0:
raise HTTPException(
status_code=400,
detail="Number of slides must be greater than 0",
)
# Checking if template is valid
if request.template not in DEFAULT_TEMPLATES:
request.template = request.template.lower()
if not request.template.startswith("custom-"):
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
template_id = request.template.replace("custom-", "")
try:
template = await sql_session.get(TemplateModel, uuid.UUID(template_id))
if not template:
raise Exception()
except Exception:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
return (presentation_id,)
async def generate_presentation_handler(
request: GeneratePresentationRequest,
presentation_id: uuid.UUID,
async_status: Optional[AsyncPresentationGenerationTaskModel],
sql_session: AsyncSession = Depends(get_async_session),
):
try:
using_slides_markdown = False
if request.slides_markdown:
using_slides_markdown = True
request.n_slides = len(request.slides_markdown)
if not using_slides_markdown:
additional_context = ""
# Updating async status
if async_status:
async_status.message = "Generating presentation outlines"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
if request.files:
documents_loader = DocumentsLoader(file_paths=request.files)
await documents_loader.load_documents()
documents = documents_loader.documents
if documents:
additional_context = "\n\n".join(documents)
# Finding number of slides to generate by considering table of contents
n_slides_to_generate = request.n_slides
if request.include_table_of_contents:
needed_toc_count = math.ceil(
(
(request.n_slides - 1)
if request.include_title_slide
else request.n_slides
)
/ 10
)
n_slides_to_generate -= math.ceil(
(request.n_slides - needed_toc_count) / 10
)
presentation_outlines_text = ""
async for chunk in generate_ppt_outline(
request.content,
n_slides_to_generate,
request.language,
additional_context,
request.tone.value,
request.verbosity.value,
request.instructions,
request.include_title_slide,
request.web_search,
):
if isinstance(chunk, HTTPException):
raise chunk
presentation_outlines_text += chunk
try:
presentation_outlines_json = dict(
dirtyjson.loads(presentation_outlines_text)
)
except Exception:
traceback.print_exc()
raise HTTPException(
status_code=400,
detail="Failed to generate presentation outlines. Please try again.",
)
presentation_outlines = PresentationOutlineModel(
**presentation_outlines_json
)
total_outlines = n_slides_to_generate
else:
# Setting outlines to slides markdown
presentation_outlines = PresentationOutlineModel(
slides=[
SlideOutlineModel(content=slide)
for slide in request.slides_markdown
]
)
total_outlines = len(request.slides_markdown)
# Updating async status
if async_status:
async_status.message = "Selecting layout for each slide"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
print("-" * 40)
print(f"Generated {total_outlines} outlines for the presentation")
# Parse Layouts
layout_model = await get_layout_by_name(request.template)
total_slide_layouts = len(layout_model.slides)
# Generate Structure
if layout_model.ordered:
presentation_structure = layout_model.to_presentation_structure()
else:
presentation_structure: PresentationStructureModel = (
await generate_presentation_structure(
presentation_outlines,
layout_model,
request.instructions,
using_slides_markdown,
)
)
presentation_structure.slides = presentation_structure.slides[:total_outlines]
for index in range(total_outlines):
random_slide_index = random.randint(0, total_slide_layouts - 1)
if index >= total_outlines:
presentation_structure.slides.append(random_slide_index)
continue
if presentation_structure.slides[index] >= total_slide_layouts:
presentation_structure.slides[index] = random_slide_index
# Injecting table of contents to the presentation structure and outlines
if request.include_table_of_contents and not using_slides_markdown:
n_toc_slides = request.n_slides - total_outlines
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout_model)
if toc_slide_layout_index != -1:
outline_index = 1 if request.include_title_slide else 0
for i in range(n_toc_slides):
outlines_to = outline_index + 10
if total_outlines == outlines_to:
outlines_to -= 1
presentation_structure.slides.insert(
i + 1 if request.include_title_slide else i,
toc_slide_layout_index,
)
toc_outline = "Table of Contents\n\n"
for outline in presentation_outlines.slides[
outline_index:outlines_to
]:
page_number = (
outline_index - i + n_toc_slides + 1
if request.include_title_slide
else outline_index - i + n_toc_slides
)
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
outline_index += 1
outline_index += 1
presentation_outlines.slides.insert(
i + 1 if request.include_title_slide else i,
SlideOutlineModel(
content=toc_outline,
),
)
# Create PresentationModel
presentation = PresentationModel(
id=presentation_id,
content=request.content,
n_slides=request.n_slides,
language=request.language,
title=get_presentation_title_from_outlines(presentation_outlines),
outlines=presentation_outlines.model_dump(),
layout=layout_model.model_dump(),
structure=presentation_structure.model_dump(),
tone=request.tone.value,
verbosity=request.verbosity.value,
instructions=request.instructions,
)
# Updating async status
if async_status:
async_status.message = "Generating slides"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
image_generation_service = ImageGenerationService(get_images_directory())
async_assets_generation_tasks = []
# 7. Generate slide content concurrently (batched), then build slides and fetch assets
slides: List[SlideModel] = []
slide_layout_indices = presentation_structure.slides
slide_layouts = [layout_model.slides[idx] for idx in slide_layout_indices]
# Schedule slide content generation and asset fetching in batches of 10
batch_size = 10
for start in range(0, len(slide_layouts), batch_size):
end = min(start + batch_size, len(slide_layouts))
print(f"Generating slides from {start} to {end}")
# Generate contents for this batch concurrently
content_tasks = [
get_slide_content_from_type_and_outline(
slide_layouts[i],
presentation_outlines.slides[i],
request.language,
request.tone.value,
request.verbosity.value,
request.instructions,
)
for i in range(start, end)
]
batch_contents: List[dict] = await asyncio.gather(*content_tasks)
# Build slides for this batch
batch_slides: List[SlideModel] = []
for offset, slide_content in enumerate(batch_contents):
i = start + offset
slide_layout = slide_layouts[i]
slide = SlideModel(
presentation=presentation_id,
layout_group=layout_model.name,
layout=slide_layout.id,
index=i,
speaker_note=slide_content.get("__speaker_note__"),
content=slide_content,
)
slides.append(slide)
batch_slides.append(slide)
# Start asset fetch tasks for just-generated slides so they run while next batch is processed
asset_tasks = [
process_slide_and_fetch_assets(image_generation_service, slide)
for slide in batch_slides
]
async_assets_generation_tasks.extend(asset_tasks)
if async_status:
async_status.message = "Fetching assets for slides"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
# Run all asset tasks concurrently while batches may still be generating content
generated_assets_list = await asyncio.gather(*async_assets_generation_tasks)
generated_assets = []
for assets_list in generated_assets_list:
generated_assets.extend(assets_list)
# 8. Save PresentationModel and Slides
sql_session.add(presentation)
sql_session.add_all(slides)
sql_session.add_all(generated_assets)
await sql_session.commit()
if async_status:
async_status.message = "Exporting presentation"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
# 9. Export
presentation_and_path = await export_presentation(
presentation_id, presentation.title or str(uuid.uuid4()), request.export_as
)
response = PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={presentation_id}",
)
if async_status:
async_status.message = "Presentation generation completed"
async_status.status = "completed"
async_status.data = response.model_dump(mode="json")
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
# Triggering webhook on success
CONCURRENT_SERVICE.run_task(
None,
WebhookService.send_webhook,
WebhookEvent.PRESENTATION_GENERATION_COMPLETED,
response.model_dump(mode="json"),
)
return response
except Exception as e:
if not isinstance(e, HTTPException):
traceback.print_exc()
e = HTTPException(status_code=500, detail="Presentation generation failed")
api_error_model = APIErrorModel.from_exception(e)
# Triggering webhook on failure
CONCURRENT_SERVICE.run_task(
None,
WebhookService.send_webhook,
WebhookEvent.PRESENTATION_GENERATION_FAILED,
api_error_model.model_dump(mode="json"),
)
if async_status:
async_status.status = "error"
async_status.message = "Presentation generation failed"
async_status.updated_at = datetime.now()
async_status.error = api_error_model.model_dump(mode="json")
sql_session.add(async_status)
await sql_session.commit()
else:
raise e
@PRESENTATION_ROUTER.post("/generate", response_model=PresentationPathAndEditPath)
async def generate_presentation_sync(
request: GeneratePresentationRequest,
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
try:
(presentation_id,) = await check_if_api_request_is_valid(request, sql_session)
return await generate_presentation_handler(
request, presentation_id, None, sql_session
)
except Exception:
traceback.print_exc()
raise HTTPException(status_code=500, detail="Presentation generation failed")
@PRESENTATION_ROUTER.post(
"/generate/async", response_model=AsyncPresentationGenerationTaskModel
)
async def generate_presentation_async(
request: GeneratePresentationRequest,
background_tasks: BackgroundTasks,
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
try:
(presentation_id,) = await check_if_api_request_is_valid(request, sql_session)
async_status = AsyncPresentationGenerationTaskModel(
status="pending",
message="Queued for generation",
data=None,
)
sql_session.add(async_status)
await sql_session.commit()
background_tasks.add_task(
generate_presentation_handler,
request,
presentation_id,
async_status=async_status,
sql_session=sql_session,
)
return async_status
except Exception as e:
if not isinstance(e, HTTPException):
print(e)
e = HTTPException(status_code=500, detail="Presentation generation failed")
raise e
@PRESENTATION_ROUTER.get(
"/status/{id}", response_model=AsyncPresentationGenerationTaskModel
)
async def check_async_presentation_generation_status(
id: str = Path(description="ID of the presentation generation task"),
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
status = await sql_session.get(AsyncPresentationGenerationTaskModel, id)
if not status:
raise HTTPException(
status_code=404, detail="No presentation generation task found"
)
return status
@PRESENTATION_ROUTER.post("/edit", response_model=PresentationPathAndEditPath)
async def edit_presentation_with_new_content(
data: Annotated[EditPresentationRequest, Body()],
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, data.presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
slides = await sql_session.scalars(
select(SlideModel).where(SlideModel.presentation == data.presentation_id)
)
new_slides = []
slides_to_delete = []
for each_slide in slides:
updated_content = None
new_slide_data = list(
filter(lambda x: x.index == each_slide.index, data.slides)
)
if new_slide_data:
updated_content = deep_update(each_slide.content, new_slide_data[0].content)
new_slides.append(
each_slide.get_new_slide(presentation.id, updated_content)
)
slides_to_delete.append(each_slide.id)
await sql_session.execute(
delete(SlideModel).where(SlideModel.id.in_(slides_to_delete))
)
sql_session.add_all(new_slides)
await sql_session.commit()
presentation_and_path = await export_presentation(
presentation.id, presentation.title or str(uuid.uuid4()), data.export_as
)
return PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={presentation.id}",
)
@PRESENTATION_ROUTER.post("/derive", response_model=PresentationPathAndEditPath)
async def derive_presentation_from_existing_one(
data: Annotated[EditPresentationRequest, Body()],
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, data.presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
slides = await sql_session.scalars(
select(SlideModel).where(SlideModel.presentation == data.presentation_id)
)
new_presentation = presentation.get_new_presentation()
new_slides = []
for each_slide in slides:
updated_content = None
new_slide_data = list(
filter(lambda x: x.index == each_slide.index, data.slides)
)
if new_slide_data:
updated_content = deep_update(each_slide.content, new_slide_data[0].content)
new_slides.append(
each_slide.get_new_slide(new_presentation.id, updated_content)
)
sql_session.add(new_presentation)
sql_session.add_all(new_slides)
await sql_session.commit()
presentation_and_path = await export_presentation(
new_presentation.id, new_presentation.title or str(uuid.uuid4()), data.export_as
)
return PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={new_presentation.id}",
)

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,90 @@
from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from models.sql.presentation import PresentationModel
from models.sql.slide import SlideModel
from services.database import get_async_session
from services.image_generation_service import ImageGenerationService
from utils.asset_directory_utils import get_images_directory
from utils.llm_calls.edit_slide import get_edited_slide_content
from utils.llm_calls.edit_slide_html import get_edited_slide_html
from utils.llm_calls.select_slide_type_on_edit import get_slide_layout_from_prompt
from utils.process_slides import process_old_and_new_slides_and_fetch_assets
import uuid
SLIDE_ROUTER = APIRouter(prefix="/slide", tags=["Slide"])
@SLIDE_ROUTER.post("/edit")
async def edit_slide(
id: Annotated[uuid.UUID, Body()],
prompt: Annotated[str, Body()],
sql_session: AsyncSession = Depends(get_async_session),
):
slide = await sql_session.get(SlideModel, id)
if not slide:
raise HTTPException(status_code=404, detail="Slide not found")
presentation = await sql_session.get(PresentationModel, slide.presentation)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_layout = presentation.get_layout()
slide_layout = await get_slide_layout_from_prompt(
prompt, presentation_layout, slide
)
edited_slide_content = await get_edited_slide_content(
prompt, slide, presentation.language, slide_layout
)
image_generation_service = ImageGenerationService(get_images_directory())
# This will mutate edited_slide_content
new_assets = await process_old_and_new_slides_and_fetch_assets(
image_generation_service,
slide.content,
edited_slide_content,
)
# Always assign a new unique id to the slide
slide.id = uuid.uuid4()
sql_session.add(slide)
slide.content = edited_slide_content
slide.layout = slide_layout.id
slide.speaker_note = edited_slide_content.get("__speaker_note__", "")
sql_session.add_all(new_assets)
await sql_session.commit()
return slide
@SLIDE_ROUTER.post("/edit-html", response_model=SlideModel)
async def edit_slide_html(
id: Annotated[uuid.UUID, Body()],
prompt: Annotated[str, Body()],
html: Annotated[Optional[str], Body()] = None,
sql_session: AsyncSession = Depends(get_async_session),
):
slide = await sql_session.get(SlideModel, id)
if not slide:
raise HTTPException(status_code=404, detail="Slide not found")
html_to_edit = html or slide.html_content
if not html_to_edit:
raise HTTPException(status_code=400, detail="No HTML to edit")
edited_slide_html = await get_edited_slide_html(prompt, html_to_edit)
# Always assign a new unique id to the slide
# This is to ensure that the nextjs can track slide updates
slide.id = uuid.uuid4()
sql_session.add(slide)
slide.html_content = edited_slide_html
await sql_session.commit()
return slide

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
from fastapi import APIRouter
from api.v1.ppt.endpoints.slide_to_html import SLIDE_TO_HTML_ROUTER, HTML_TO_REACT_ROUTER, HTML_EDIT_ROUTER, LAYOUT_MANAGEMENT_ROUTER
from api.v1.ppt.endpoints.presentation import PRESENTATION_ROUTER
from api.v1.ppt.endpoints.anthropic import ANTHROPIC_ROUTER
from api.v1.ppt.endpoints.google import GOOGLE_ROUTER
from api.v1.ppt.endpoints.openai import OPENAI_ROUTER
from api.v1.ppt.endpoints.files import FILES_ROUTER
from api.v1.ppt.endpoints.pptx_slides import PPTX_SLIDES_ROUTER
from api.v1.ppt.endpoints.pdf_slides import PDF_SLIDES_ROUTER
from api.v1.ppt.endpoints.fonts import FONTS_ROUTER
from api.v1.ppt.endpoints.icons import ICONS_ROUTER
from api.v1.ppt.endpoints.images import IMAGES_ROUTER
from api.v1.ppt.endpoints.ollama import OLLAMA_ROUTER
from api.v1.ppt.endpoints.outlines import OUTLINES_ROUTER
from api.v1.ppt.endpoints.slide import SLIDE_ROUTER
from api.v1.ppt.endpoints.pptx_slides import PPTX_FONTS_ROUTER
API_V1_PPT_ROUTER = APIRouter(prefix="/api/v1/ppt")
API_V1_PPT_ROUTER.include_router(FILES_ROUTER)
API_V1_PPT_ROUTER.include_router(FONTS_ROUTER)
API_V1_PPT_ROUTER.include_router(OUTLINES_ROUTER)
API_V1_PPT_ROUTER.include_router(PRESENTATION_ROUTER)
API_V1_PPT_ROUTER.include_router(PPTX_SLIDES_ROUTER)
API_V1_PPT_ROUTER.include_router(SLIDE_ROUTER)
API_V1_PPT_ROUTER.include_router(SLIDE_TO_HTML_ROUTER)
API_V1_PPT_ROUTER.include_router(HTML_TO_REACT_ROUTER)
API_V1_PPT_ROUTER.include_router(HTML_EDIT_ROUTER)
API_V1_PPT_ROUTER.include_router(LAYOUT_MANAGEMENT_ROUTER)
API_V1_PPT_ROUTER.include_router(IMAGES_ROUTER)
API_V1_PPT_ROUTER.include_router(ICONS_ROUTER)
API_V1_PPT_ROUTER.include_router(OLLAMA_ROUTER)
API_V1_PPT_ROUTER.include_router(PDF_SLIDES_ROUTER)
API_V1_PPT_ROUTER.include_router(OPENAI_ROUTER)
API_V1_PPT_ROUTER.include_router(ANTHROPIC_ROUTER)
API_V1_PPT_ROUTER.include_router(GOOGLE_ROUTER)
API_V1_PPT_ROUTER.include_router(PPTX_FONTS_ROUTER)

View file

@ -0,0 +1,53 @@
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Path
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from enums.webhook_event import WebhookEvent
from models.sql.webhook_subscription import WebhookSubscription
from services.database import get_async_session
API_V1_WEBHOOK_ROUTER = APIRouter(prefix="/api/v1/webhook", tags=["Webhook"])
class SubscribeToWebhookRequest(BaseModel):
url: str = Field(description="The URL to send the webhook to")
secret: Optional[str] = Field(None, description="The secret to use for the webhook")
event: WebhookEvent = Field(description="The event to subscribe to")
class SubscribeToWebhookResponse(BaseModel):
id: str
@API_V1_WEBHOOK_ROUTER.post(
"/subscribe", response_model=SubscribeToWebhookResponse, status_code=201
)
async def subscribe_to_webhook(
body: SubscribeToWebhookRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
webhook_subscription = WebhookSubscription(
url=body.url,
secret=body.secret,
event=body.event,
)
sql_session.add(webhook_subscription)
await sql_session.commit()
return SubscribeToWebhookResponse(id=webhook_subscription.id)
@API_V1_WEBHOOK_ROUTER.delete("/unsubscribe", status_code=204)
async def unsubscribe_to_webhook(
id: str = Body(
embed=True, description="The ID of the webhook subscription to unsubscribe from"
),
sql_session: AsyncSession = Depends(get_async_session),
):
webhook_subscription = await sql_session.get(WebhookSubscription, id)
if not webhook_subscription:
raise HTTPException(404, "Webhook subscription not found")
await sql_session.delete(webhook_subscription)
await sql_session.commit()

63510
backend/assets/icons.json Normal file

File diff suppressed because it is too large Load diff

View file

View file

@ -0,0 +1,20 @@
PDF_MIME_TYPES = ["application/pdf"]
TEXT_MIME_TYPES = ["text/plain"]
POWERPOINT_TYPES = [
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
]
WORD_TYPES = [
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]
SPREADSHEET_TYPES = ["text/csv", "application/csv"]
PNG_MIME_TYPES = ["image/png"]
JPEG_MIME_TYPES = ["image/jpeg"]
WEBP_MIME_TYPES = ["image/webp"]
UPLOAD_ACCEPTED_FILE_TYPES = (
PDF_MIME_TYPES + TEXT_MIME_TYPES + POWERPOINT_TYPES + WORD_TYPES
)

6
backend/constants/llm.py Normal file
View file

@ -0,0 +1,6 @@
OPENAI_URL = "https://api.openai.com/v1"
# Default models
DEFAULT_OPENAI_MODEL = "gpt-4.1"
DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash"
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"

View file

@ -0,0 +1 @@
DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift"]

View file

@ -0,0 +1,180 @@
from models.ollama_model_metadata import OllamaModelMetadata
SUPPORTED_OLLAMA_MODELS = {
"llama3:8b": OllamaModelMetadata(
label="Llama 3:8b",
value="llama3:8b",
size="4.7GB",
),
"llama3:70b": OllamaModelMetadata(
label="Llama 3:70b",
value="llama3:70b",
size="40GB",
),
"llama3.1:8b": OllamaModelMetadata(
label="Llama 3.1:8b",
value="llama3.1:8b",
size="4.9GB",
),
"llama3.1:70b": OllamaModelMetadata(
label="Llama 3.1:70b",
value="llama3.1:70b",
size="43GB",
),
"llama3.1:405b": OllamaModelMetadata(
label="Llama 3.1:405b",
value="llama3.1:405b",
size="243GB",
),
"llama3.2:1b": OllamaModelMetadata(
label="Llama 3.2:1b",
value="llama3.2:1b",
size="1.3GB",
),
"llama3.2:3b": OllamaModelMetadata(
label="Llama 3.2:3b",
value="llama3.2:3b",
size="2GB",
),
"llama3.3:70b": OllamaModelMetadata(
label="Llama 3.3:70b",
value="llama3.3:70b",
size="43GB",
),
"llama4:16x17b": OllamaModelMetadata(
label="Llama 4:16x17b",
value="llama4:16x17b",
size="67GB",
),
"llama4:128x17b": OllamaModelMetadata(
label="Llama 4:128x17b",
value="llama4:128x17b",
size="245GB",
),
}
SUPPORTED_GEMMA_MODELS = {
"gemma3:1b": OllamaModelMetadata(
label="Gemma 3:1b",
value="gemma3:1b",
size="815MB",
),
"gemma3:4b": OllamaModelMetadata(
label="Gemma 3:4b",
value="gemma3:4b",
size="3.3GB",
),
"gemma3:12b": OllamaModelMetadata(
label="Gemma 3:12b",
value="gemma3:12b",
size="8.1GB",
),
"gemma3:27b": OllamaModelMetadata(
label="Gemma 3:27b",
value="gemma3:27b",
size="17GB",
),
}
SUPPORTED_DEEPSEEK_MODELS = {
"deepseek-r1:1.5b": OllamaModelMetadata(
label="DeepSeek R1:1.5b",
value="deepseek-r1:1.5b",
size="1.1GB",
),
"deepseek-r1:7b": OllamaModelMetadata(
label="DeepSeek R1:7b",
value="deepseek-r1:7b",
size="4.7GB",
),
"deepseek-r1:8b": OllamaModelMetadata(
label="DeepSeek R1:8b",
value="deepseek-r1:8b",
size="5.2GB",
),
"deepseek-r1:14b": OllamaModelMetadata(
label="DeepSeek R1:14b",
value="deepseek-r1:14b",
size="9GB",
),
"deepseek-r1:32b": OllamaModelMetadata(
label="DeepSeek R1:32b",
value="deepseek-r1:32b",
size="20GB",
),
"deepseek-r1:70b": OllamaModelMetadata(
label="DeepSeek R1:70b",
value="deepseek-r1:70b",
size="43GB",
),
"deepseek-r1:671b": OllamaModelMetadata(
label="DeepSeek R1:671b",
value="deepseek-r1:671b",
size="404GB",
),
}
SUPPORTED_QWEN_MODELS = {
"qwen3:0.6b": OllamaModelMetadata(
label="Qwen 3:0.6b",
value="qwen3:0.6b",
size="523MB",
),
"qwen3:1.7b": OllamaModelMetadata(
label="Qwen 3:1.7b",
value="qwen3:1.7b",
size="1.4GB",
),
"qwen3:4b": OllamaModelMetadata(
label="Qwen 3:4b",
value="qwen3:4b",
size="2.6GB",
),
"qwen3:8b": OllamaModelMetadata(
label="Qwen 3:8b",
value="qwen3:8b",
size="5.2GB",
),
"qwen3:14b": OllamaModelMetadata(
label="Qwen 3:14b",
value="qwen3:14b",
size="9.3GB",
),
"qwen3:30b": OllamaModelMetadata(
label="Qwen 3:30b",
value="qwen3:30b",
size="19GB",
),
"qwen3:32b": OllamaModelMetadata(
label="Qwen 3:32b",
value="qwen3:32b",
size="20GB",
),
"qwen3:235b": OllamaModelMetadata(
label="Qwen 3:235b",
value="qwen3:235b",
size="142GB",
),
}
SUPPORTED_GPT_OSS_MODELS = {
"gpt-oss:20b": OllamaModelMetadata(
label="GPT-OSS 20b",
value="gpt-oss:20b",
size="14GB",
),
"gpt-oss:120b": OllamaModelMetadata(
label="GPT-OSS 120b",
value="gpt-oss:120b",
size="65GB",
),
}
SUPPORTED_OLLAMA_MODELS = {
**SUPPORTED_OLLAMA_MODELS,
**SUPPORTED_GEMMA_MODELS,
**SUPPORTED_DEEPSEEK_MODELS,
**SUPPORTED_QWEN_MODELS,
**SUPPORTED_GPT_OSS_MODELS,
}

View file

View file

@ -0,0 +1,11 @@
from enum import Enum
class ImageProvider(Enum):
PEXELS = "pexels"
PIXABAY = "pixabay"
GEMINI_FLASH = "gemini_flash"
NANOBANANA_PRO = "nanobanana_pro"
DALLE3 = "dall-e-3"
GPT_IMAGE_1_5 = "gpt-image-1.5"
COMFYUI = "comfyui"

View file

@ -0,0 +1,8 @@
from enum import Enum
class LLMCallType(Enum):
UNSTRUCTURED = "unstructured"
UNSTRUCTURED_STREAM = "unstructured_stream"
STRUCTURED = "structured"
STRUCTURED_STREAM = "structured_stream"

View file

@ -0,0 +1,9 @@
from enum import Enum
class LLMProvider(Enum):
OLLAMA = "ollama"
OPENAI = "openai"
GOOGLE = "google"
ANTHROPIC = "anthropic"
CUSTOM = "custom"

11
backend/enums/tone.py Normal file
View file

@ -0,0 +1,11 @@
from enum import Enum
class Tone(str, Enum):
DEFAULT = "default"
CASUAL = "casual"
PROFESSIONAL = "professional"
FUNNY = "funny"
EDUCATIONAL = "educational"
SALES_PITCH = "sales_pitch"

View file

@ -0,0 +1,8 @@
from enum import Enum
class Verbosity(str, Enum):
CONCISE = "concise"
STANDARD = "standard"
TEXT_HEAVY = "text-heavy"

View file

@ -0,0 +1,6 @@
from enum import Enum
class WebhookEvent(str, Enum):
PRESENTATION_GENERATION_COMPLETED = "presentation.generation.completed"
PRESENTATION_GENERATION_FAILED = "presentation.generation.failed"

68
backend/mcp_server.py Normal file
View file

@ -0,0 +1,68 @@
import sys
import argparse
import asyncio
import traceback
import httpx
from fastmcp import FastMCP
import json
with open("openai_spec.json", "r") as f:
openapi_spec = json.load(f)
async def main():
try:
print("DEBUG: MCP (OpenAPI) Server startup initiated")
parser = argparse.ArgumentParser(
description="Run the MCP server (from OpenAPI)"
)
parser.add_argument(
"--port", type=int, default=8001, help="Port for the MCP HTTP server"
)
parser.add_argument(
"--name",
type=str,
default="Presenton API (OpenAPI)",
help="Display name for the generated MCP server",
)
args = parser.parse_args()
print(f"DEBUG: Parsed args - port={args.port}")
# Create an HTTP client that the MCP server will use to call the API
api_client = httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=60.0)
# Build MCP server from OpenAPI
print("DEBUG: Creating FastMCP server from OpenAPI spec...")
mcp = FastMCP.from_openapi(
openapi_spec=openapi_spec,
client=api_client,
name=args.name,
)
print("DEBUG: MCP server created from OpenAPI successfully")
# Start the MCP server
uvicorn_config = {"reload": True}
print(f"DEBUG: Starting MCP server on host=127.0.0.1, port={args.port}")
await mcp.run_async(
transport="http",
host="127.0.0.1",
port=args.port,
uvicorn_config=uvicorn_config,
)
print("DEBUG: MCP server run_async completed")
except Exception as e:
print(f"ERROR: MCP server startup failed: {e}")
print(f"ERROR: Traceback: {traceback.format_exc()}")
raise
if __name__ == "__main__":
print("DEBUG: Starting MCP (OpenAPI) main function")
try:
asyncio.run(main())
except Exception as e:
print(f"FATAL ERROR: {e}")
print(f"FATAL TRACEBACK: {traceback.format_exc()}")
sys.exit(1)

View file

75
backend/migrations/env.py Normal file
View file

@ -0,0 +1,75 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from sqlmodel import SQLModel
# Import ALL models so they register with SQLModel.metadata
# Existing models
from models.sql.presentation import PresentationModel # noqa: F401
from models.sql.slide import SlideModel # noqa: F401
from models.sql.key_value import KeyValueSqlModel # noqa: F401
from models.sql.image_asset import ImageAsset # noqa: F401
from models.sql.presentation_layout_code import PresentationLayoutCodeModel # noqa: F401
from models.sql.template import TemplateModel # noqa: F401
from models.sql.webhook_subscription import WebhookSubscription # noqa: F401
from models.sql.async_presentation_generation_status import AsyncPresentationGenerationTaskModel # noqa: F401
from models.sql.ollama_pull_status import OllamaPullStatus # noqa: F401
# New models
from models.sql.user import UserModel # noqa: F401
from models.sql.client import ClientModel # noqa: F401
from models.sql.team import TeamModel # noqa: F401
from models.sql.team_membership import TeamMembershipModel # noqa: F401
from models.sql.brand_config import BrandConfigModel # noqa: F401
from models.sql.master_deck import MasterDeckModel # noqa: F401
from models.sql.audit_log import AuditLogModel # noqa: F401
from models.sql.job import JobModel # noqa: F401
from utils.db_utils import get_database_url_and_connect_args
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
url, _ = get_database_url_and_connect_args()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
url, connect_args = get_database_url_and_connect_args()
connectable = async_engine_from_config(
{"sqlalchemy.url": url},
prefix="sqlalchemy.",
poolclass=pool.NullPool,
connect_args=connect_args,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View file

@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

View file

@ -0,0 +1,13 @@
from fastapi import HTTPException
from pydantic import BaseModel
class APIErrorModel(BaseModel):
status_code: int
detail: str
@classmethod
def from_exception(cls, e: Exception) -> "APIErrorModel":
if isinstance(e, HTTPException):
return APIErrorModel(status_code=e.status_code, detail=e.detail)
return APIErrorModel(status_code=500, detail=str(e))

View file

@ -0,0 +1,6 @@
from pydantic import BaseModel
class DecomposedFileInfo(BaseModel):
name: str
file_path: str

View file

@ -0,0 +1,13 @@
from pydantic import BaseModel
from models.presentation_outline_model import SlideOutlineModel
class DocumentChunk(BaseModel):
heading: str
content: str
heading_index: int
score: float
def to_slide_outline(self) -> SlideOutlineModel:
return SlideOutlineModel(content=f"{self.heading}\n{self.content}")

View file

@ -0,0 +1,42 @@
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
from enums.tone import Tone
from enums.verbosity import Verbosity
class GeneratePresentationRequest(BaseModel):
content: str = Field(..., description="The content for generating the presentation")
slides_markdown: Optional[List[str]] = Field(
default=None, description="The markdown for the slides"
)
instructions: Optional[str] = Field(
default=None, description="The instruction for generating the presentation"
)
tone: Tone = Field(default=Tone.DEFAULT, description="The tone to use for the text")
verbosity: Verbosity = Field(
default=Verbosity.STANDARD, description="How verbose the presentation should be"
)
web_search: bool = Field(default=False, description="Whether to enable web search")
n_slides: int = Field(default=8, description="Number of slides to generate")
language: str = Field(
default="English", description="Language for the presentation"
)
template: str = Field(
default="general", description="Template to use for the presentation"
)
include_table_of_contents: bool = Field(
default=False, description="Whether to include a table of contents"
)
include_title_slide: bool = Field(
default=True, description="Whether to include a title slide"
)
files: Optional[List[str]] = Field(
default=None, description="Files to use for the presentation"
)
export_as: Literal["pptx", "pdf"] = Field(
default="pptx", description="Export format"
)
trigger_webhook: bool = Field(
default=False, description="Whether to trigger subscribed webhooks"
)

View file

@ -0,0 +1,10 @@
from typing import Optional
from pydantic import BaseModel
class ImagePrompt(BaseModel):
prompt: str
theme_prompt: Optional[str] = None
def get_image_prompt(self, with_theme: bool = False) -> str:
return f"{self.prompt}, {self.theme_prompt}" if with_theme else self.prompt

View file

@ -0,0 +1,14 @@
from typing import List
from pydantic import BaseModel
class DictGuide(BaseModel):
key: str
class ListGuide(BaseModel):
index: int
class JsonPathGuide(BaseModel):
guides: List[DictGuide | ListGuide]

View file

@ -0,0 +1,59 @@
from typing import Any, List, Literal, Optional
from pydantic import BaseModel
from google.genai.types import Content as GoogleContent
from models.llm_tool_call import AnthropicToolCall
class LLMMessage(BaseModel):
pass
class LLMUserMessage(LLMMessage):
role: Literal["user"] = "user"
content: str
class LLMSystemMessage(LLMMessage):
role: Literal["system"] = "system"
content: str
class OpenAIAssistantMessage(LLMMessage):
role: Literal["assistant"] = "assistant"
content: str | None = None
tool_calls: Optional[List[dict]] = None
class GoogleAssistantMessage(LLMMessage):
role: Literal["assistant"] = "assistant"
content: GoogleContent
class AnthropicAssistantMessage(LLMMessage):
role: Literal["assistant"] = "assistant"
content: List[AnthropicToolCall]
class AnthropicToolCallMessage(LLMMessage):
type: Literal["tool_result"] = "tool_result"
tool_use_id: str
content: str
class AnthropicUserMessage(LLMMessage):
role: Literal["user"] = "user"
content: List[AnthropicToolCallMessage]
class OpenAIToolCallMessage(LLMMessage):
role: Literal["tool"] = "tool"
content: str
tool_call_id: str
class GoogleToolCallMessage(LLMMessage):
role: Literal["tool"] = "tool"
id: Optional[str] = None
name: str
response: dict

View file

@ -0,0 +1,30 @@
from typing import Literal, Optional
from pydantic import BaseModel
class LLMToolCall(BaseModel):
pass
class OpenAIToolCallFunction(BaseModel):
name: str
arguments: str
class OpenAIToolCall(LLMToolCall):
id: str
type: Literal["function"] = "function"
function: OpenAIToolCallFunction
class GoogleToolCall(LLMToolCall):
id: Optional[str] = None
name: str
arguments: Optional[dict] = None
class AnthropicToolCall(LLMToolCall):
type: Literal["tool_use"] = "tool_use"
id: str
name: str
input: object

View file

@ -0,0 +1,29 @@
from typing import Any, Callable, Coroutine, Optional
from pydantic import BaseModel, Field
class LLMTool(BaseModel):
pass
class LLMDynamicTool(LLMTool):
name: str
description: str
parameters: dict = {}
handler: Callable[..., Coroutine[Any, Any, str]]
class SearchWebTool(LLMTool):
"""
Search the web for information.
"""
query: str = Field(description="The query to search the web for")
class GetCurrentDatetimeTool(LLMTool):
"""
Get the current datetime.
"""
pass

View file

@ -0,0 +1,7 @@
from pydantic import BaseModel
class OllamaModelMetadata(BaseModel):
label: str
value: str
size: str

View file

@ -0,0 +1,10 @@
from typing import Optional
from pydantic import BaseModel
class OllamaModelStatus(BaseModel):
name: str
size: Optional[int] = None
downloaded: Optional[int] = None
status: str
done: bool

View file

@ -0,0 +1,171 @@
from enum import Enum
from typing import Annotated, List, Literal, Optional
from annotated_types import Len
from pydantic import BaseModel
from pptx.util import Pt
from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR_TYPE
class PptxBoxShapeEnum(Enum):
RECTANGLE = "rectangle"
CIRCLE = "circle"
class PptxObjectFitEnum(Enum):
CONTAIN = "contain"
COVER = "cover"
FILL = "fill"
class PptxSpacingModel(BaseModel):
top: int = 0
bottom: int = 0
left: int = 0
right: int = 0
@classmethod
def all(cls, num: int):
return PptxSpacingModel(top=num, left=num, bottom=num, right=num)
class PptxPositionModel(BaseModel):
left: int = 0
top: int = 0
width: int = 0
height: int = 0
@classmethod
def for_textbox(cls, left: int, top: int, width: int):
return cls(left=left, top=top, width=width, height=100)
def to_pt_list(self) -> List[int]:
return [Pt(self.left), Pt(self.top), Pt(self.width), Pt(self.height)]
def to_pt_xyxy(self) -> List[int]:
return [
Pt(self.left),
Pt(self.top),
Pt(self.left + self.width),
Pt(self.top + self.height),
]
class PptxFontModel(BaseModel):
name: str = "Inter"
size: int = 16
italic: bool = False
color: str = "000000"
font_weight: Optional[int] = 400
underline: Optional[bool] = None
strike: Optional[bool] = None
class PptxFillModel(BaseModel):
color: str
opacity: float = 1.0
class PptxStrokeModel(BaseModel):
color: str
thickness: float
opacity: float = 1.0
class PptxShadowModel(BaseModel):
radius: int
offset: int = 0
color: str = "000000"
opacity: float = 0.5
angle: int = 0
class PptxTextRunModel(BaseModel):
text: str
font: Optional[PptxFontModel] = None
class PptxParagraphModel(BaseModel):
spacing: Optional[PptxSpacingModel] = None
alignment: Optional[PP_ALIGN] = None
font: Optional[PptxFontModel] = None
line_height: Optional[float] = None
text: Optional[str] = None
text_runs: Optional[List[PptxTextRunModel]] = None
class PptxObjectFitModel(BaseModel):
fit: Optional[PptxObjectFitEnum] = None
focus: Optional[
Annotated[List[Optional[float]], Len(min_length=2, max_length=2)]
] = None
class PptxPictureModel(BaseModel):
is_network: bool
path: str
class PptxShapeModel(BaseModel):
shape_type: Literal["textbox", "autoshape", "picture", "connector"]
class PptxTextBoxModel(PptxShapeModel):
shape_type: Literal["textbox"] = "textbox"
margin: Optional[PptxSpacingModel] = None
fill: Optional[PptxFillModel] = None
position: PptxPositionModel
text_wrap: bool = True
paragraphs: List[PptxParagraphModel]
class PptxAutoShapeBoxModel(PptxShapeModel):
shape_type: Literal["autoshape"] = "autoshape"
type: MSO_AUTO_SHAPE_TYPE = MSO_AUTO_SHAPE_TYPE.RECTANGLE
margin: Optional[PptxSpacingModel] = None
fill: Optional[PptxFillModel] = None
stroke: Optional[PptxStrokeModel] = None
shadow: Optional[PptxShadowModel] = None
position: PptxPositionModel
text_wrap: bool = True
border_radius: Optional[int] = None
paragraphs: Optional[List[PptxParagraphModel]] = None
class PptxPictureBoxModel(PptxShapeModel):
shape_type: Literal["picture"] = "picture"
position: PptxPositionModel
margin: Optional[PptxSpacingModel] = None
clip: bool = True
opacity: Optional[float] = None
invert: bool = False
border_radius: Optional[List[int]] = None
shape: Optional[PptxBoxShapeEnum] = None
object_fit: Optional[PptxObjectFitModel] = None
picture: PptxPictureModel
class PptxConnectorModel(PptxShapeModel):
shape_type: Literal["connector"] = "connector"
type: MSO_CONNECTOR_TYPE = MSO_CONNECTOR_TYPE.STRAIGHT
position: PptxPositionModel
thickness: float = 0.5
color: str = "000000"
opacity: float = 1.0
class PptxSlideModel(BaseModel):
background: Optional[PptxFillModel] = None
note: Optional[str] = None
shapes: List[
PptxTextBoxModel
| PptxAutoShapeBoxModel
| PptxConnectorModel
| PptxPictureBoxModel
]
class PptxPresentationModel(BaseModel):
name: Optional[str] = None
shapes: Optional[List[PptxShapeModel]] = None
slides: List[PptxSlideModel]

View file

@ -0,0 +1,11 @@
from pydantic import BaseModel
import uuid
class PresentationAndPath(BaseModel):
presentation_id: uuid.UUID
path: str
class PresentationPathAndEditPath(PresentationAndPath):
edit_path: str

View file

@ -0,0 +1,14 @@
from typing import List, Literal
from pydantic import BaseModel
import uuid
class SlideContentUpdate(BaseModel):
index: int
content: dict
class EditPresentationRequest(BaseModel):
presentation_id: uuid.UUID
slides: List[SlideContentUpdate]
export_as: Literal["pptx", "pdf"] = "pptx"

View file

@ -0,0 +1,39 @@
from typing import List, Optional
from fastapi import HTTPException
from pydantic import BaseModel, Field
from models.presentation_structure_model import PresentationStructureModel
class SlideLayoutModel(BaseModel):
id: str
name: Optional[str] = None
description: Optional[str] = None
json_schema: dict
class PresentationLayoutModel(BaseModel):
name: str
ordered: bool = Field(default=False)
slides: List[SlideLayoutModel]
def get_slide_layout_index(self, slide_layout_id: str) -> int:
for index, slide in enumerate(self.slides):
if slide.id == slide_layout_id:
return index
raise HTTPException(
status_code=404, detail=f"Slide layout {slide_layout_id} not found"
)
def to_presentation_structure(self):
return PresentationStructureModel(
slides=[index for index in range(len(self.slides))]
)
def to_string(self):
message = f"## Presentation Layout\n\n"
for index, slide in enumerate(self.slides):
message += f"### Slide Layout: {index}: \n"
message += f"- Name: {slide.name or slide.json_schema.get('title')} \n"
message += f"- Description: {slide.description} \n\n"
return message

View file

@ -0,0 +1,17 @@
from typing import List
from pydantic import BaseModel
class SlideOutlineModel(BaseModel):
content: str
class PresentationOutlineModel(BaseModel):
slides: List[SlideOutlineModel]
def to_string(self):
message = ""
for i, slide in enumerate(self.slides):
message += f"## Slide {i+1}:\n"
message += f" - Content: {slide} \n"
return message

View file

@ -0,0 +1,6 @@
from typing import List
from pydantic import BaseModel, Field
class PresentationStructureModel(BaseModel):
slides: List[int] = Field(description="List of slide layout indexes")

View file

@ -0,0 +1,20 @@
from typing import List, Optional
from datetime import datetime
import uuid
from pydantic import BaseModel
from models.sql.slide import SlideModel
class PresentationWithSlides(BaseModel):
id: uuid.UUID
content: str
n_slides: int
language: str
title: Optional[str] = None
created_at: datetime
updated_at: datetime
tone: Optional[str] = None
verbosity: Optional[str] = None
slides: List[SlideModel]

View file

@ -0,0 +1,5 @@
from pydantic import BaseModel
class SlideLayoutIndex(BaseModel):
index: int

View file

@ -0,0 +1,22 @@
from datetime import datetime
import secrets
from typing import Optional
import uuid
from sqlalchemy import JSON, Column
from sqlmodel import Field, SQLModel
class AsyncPresentationGenerationTaskModel(SQLModel, table=True):
__tablename__ = "async_presentation_generation_tasks"
id: str = Field(
default_factory=lambda: f"task-{secrets.token_hex(32)}", primary_key=True
)
status: str
message: Optional[str] = None
error: Optional[dict] = Field(sa_column=Column(JSON), default=None)
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
data: Optional[dict] = Field(sa_column=Column(JSON), default=None)

View file

@ -0,0 +1,31 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import JSON, Column, DateTime, ForeignKey, String
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class AuditLogModel(SQLModel, table=True):
__tablename__ = "audit_logs"
id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4)
user_id: Optional[uuid.UUID] = Field(
sa_column=Column(ForeignKey("users.id"), nullable=True), default=None
)
action: str
resource_type: str
resource_id: Optional[uuid.UUID] = Field(default=None)
client_id: Optional[uuid.UUID] = Field(default=None)
details: Optional[dict] = Field(sa_column=Column(JSON), default=None)
ip_address: Optional[str] = Field(
sa_column=Column(String, nullable=True), default=None
)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
nullable=False,
default=get_current_utc_datetime,
index=True,
),
)

View file

@ -0,0 +1,39 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import JSON, Column, DateTime, ForeignKey, String
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class BrandConfigModel(SQLModel, table=True):
__tablename__ = "brand_configs"
id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4)
client_id: uuid.UUID = Field(
sa_column=Column(ForeignKey("clients.id"), unique=True)
)
primary_colors: Optional[list] = Field(sa_column=Column(JSON), default=None)
secondary_colors: Optional[list] = Field(sa_column=Column(JSON), default=None)
fonts: Optional[dict] = Field(sa_column=Column(JSON), default=None)
logo_paths: Optional[list] = Field(sa_column=Column(JSON), default=None)
voice_rules: Optional[str] = Field(
sa_column=Column(String, nullable=True), default=None
)
voice_examples: Optional[list] = Field(sa_column=Column(JSON), default=None)
guideline_doc_path: Optional[str] = Field(
sa_column=Column(String, nullable=True), default=None
)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
),
)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
nullable=False,
default=get_current_utc_datetime,
onupdate=get_current_utc_datetime,
),
)

View file

@ -0,0 +1,35 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import Column, DateTime, String, Boolean, Integer
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class ClientModel(SQLModel, table=True):
__tablename__ = "clients"
id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4)
name: str
slug: str = Field(sa_column=Column(String, unique=True, index=True))
logo_path: Optional[str] = Field(
sa_column=Column(String, nullable=True), default=None
)
retention_days: Optional[int] = Field(
sa_column=Column(Integer, nullable=True), default=None
)
review_policy: str = Field(default="self_approve") # self_approve, require_reviewer
is_active: bool = Field(sa_column=Column(Boolean, default=True, nullable=False))
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
),
)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
nullable=False,
default=get_current_utc_datetime,
onupdate=get_current_utc_datetime,
),
)

View file

@ -0,0 +1,20 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import JSON, Column, DateTime
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class ImageAsset(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
),
)
is_uploaded: bool = Field(default=False)
path: str
extras: Optional[dict] = Field(sa_column=Column(JSON), default=None)

37
backend/models/sql/job.py Normal file
View file

@ -0,0 +1,37 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Integer
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class JobModel(SQLModel, table=True):
__tablename__ = "jobs"
id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4)
user_id: uuid.UUID = Field(sa_column=Column(ForeignKey("users.id")))
client_id: uuid.UUID = Field(sa_column=Column(ForeignKey("clients.id")))
presentation_id: Optional[uuid.UUID] = Field(
sa_column=Column(ForeignKey("presentations.id"), nullable=True), default=None
)
job_type: str # generate_presentation, parse_master_deck
status: str = Field(default="queued") # queued, processing, completed, failed
progress: int = Field(sa_column=Column(Integer, default=0))
progress_message: Optional[str] = Field(
sa_column=Column(String, nullable=True), default=None
)
error_message: Optional[str] = Field(
sa_column=Column(String, nullable=True), default=None
)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
),
)
started_at: Optional[datetime] = Field(
sa_column=Column(DateTime(timezone=True), nullable=True), default=None
)
completed_at: Optional[datetime] = Field(
sa_column=Column(DateTime(timezone=True), nullable=True), default=None
)

View file

@ -0,0 +1,8 @@
import uuid
from sqlmodel import Field, Column, JSON, SQLModel
class KeyValueSqlModel(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
key: str = Field(index=True)
value: dict = Field(sa_column=Column(JSON))

View file

@ -0,0 +1,38 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import JSON, Column, DateTime, ForeignKey, String, Boolean
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class MasterDeckModel(SQLModel, table=True):
__tablename__ = "master_decks"
id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4)
client_id: uuid.UUID = Field(sa_column=Column(ForeignKey("clients.id")))
name: str
description: Optional[str] = Field(
sa_column=Column(String, nullable=True), default=None
)
original_file_path: str
thumbnail_path: Optional[str] = Field(
sa_column=Column(String, nullable=True), default=None
)
parsed_config: Optional[dict] = Field(sa_column=Column(JSON), default=None)
layouts: Optional[list] = Field(sa_column=Column(JSON), default=None)
parse_status: str = Field(default="pending") # pending, processing, completed, failed
is_active: bool = Field(sa_column=Column(Boolean, default=True, nullable=False))
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
),
)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
nullable=False,
default=get_current_utc_datetime,
onupdate=get_current_utc_datetime,
),
)

View file

@ -0,0 +1,9 @@
from datetime import datetime
import uuid
from sqlmodel import Field, Column, JSON, SQLModel, DateTime
class OllamaPullStatus(SQLModel, table=True):
id: str = Field(primary_key=True)
last_updated: datetime = Field(sa_column=Column(DateTime, default=datetime.now))
status: dict = Field(sa_column=Column(JSON))

View file

@ -0,0 +1,102 @@
from datetime import datetime
from typing import List, Optional
import uuid
from sqlalchemy import JSON, Column, DateTime, ForeignKey, String
from sqlmodel import Boolean, Field, SQLModel
from models.presentation_layout import PresentationLayoutModel
from models.presentation_outline_model import PresentationOutlineModel
from models.presentation_structure_model import PresentationStructureModel
from utils.datetime_utils import get_current_utc_datetime
class PresentationModel(SQLModel, table=True):
__tablename__ = "presentations"
id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4)
content: str
n_slides: int
language: str
title: Optional[str] = None
file_paths: Optional[List[str]] = Field(sa_column=Column(JSON), default=None)
outlines: Optional[dict] = Field(sa_column=Column(JSON), default=None)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
),
)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
nullable=False,
default=get_current_utc_datetime,
onupdate=get_current_utc_datetime,
),
)
layout: Optional[dict] = Field(sa_column=Column(JSON), default=None)
structure: Optional[dict] = Field(sa_column=Column(JSON), default=None)
instructions: Optional[str] = Field(sa_column=Column(String), default=None)
tone: Optional[str] = Field(sa_column=Column(String), default=None)
verbosity: Optional[str] = Field(sa_column=Column(String), default=None)
include_table_of_contents: bool = Field(sa_column=Column(Boolean), default=False)
include_title_slide: bool = Field(sa_column=Column(Boolean), default=True)
web_search: bool = Field(sa_column=Column(Boolean), default=False)
# Multi-tenant fields (all nullable for backward compat with existing data)
owner_id: Optional[uuid.UUID] = Field(
sa_column=Column(ForeignKey("users.id"), nullable=True), default=None
)
client_id: Optional[uuid.UUID] = Field(
sa_column=Column(ForeignKey("clients.id"), nullable=True), default=None
)
master_deck_id: Optional[uuid.UUID] = Field(
sa_column=Column(ForeignKey("master_decks.id"), nullable=True), default=None
)
status: str = Field(sa_column=Column(String, default="draft")) # draft, in_review, approved
review_comment: Optional[str] = Field(
sa_column=Column(String, nullable=True), default=None
)
source_type: Optional[str] = Field(
sa_column=Column(String, nullable=True), default=None
) # brief, url, manual
is_saved: bool = Field(sa_column=Column(Boolean, default=False))
deleted_at: Optional[datetime] = Field(
sa_column=Column(DateTime(timezone=True), nullable=True), default=None
)
def get_new_presentation(self):
return PresentationModel(
id=uuid.uuid4(),
content=self.content,
n_slides=self.n_slides,
language=self.language,
title=self.title,
file_paths=self.file_paths,
outlines=self.outlines,
layout=self.layout,
structure=self.structure,
instructions=self.instructions,
tone=self.tone,
verbosity=self.verbosity,
include_table_of_contents=self.include_table_of_contents,
include_title_slide=self.include_title_slide,
)
def get_presentation_outline(self):
if not self.outlines:
return None
return PresentationOutlineModel(**self.outlines)
def get_layout(self):
return PresentationLayoutModel(**self.layout)
def set_layout(self, layout: PresentationLayoutModel):
self.layout = layout.model_dump()
def get_structure(self):
if not self.structure:
return None
return PresentationStructureModel(**self.structure)
def set_structure(self, structure: PresentationStructureModel):
self.structure = structure.model_dump()

View file

@ -0,0 +1,37 @@
from datetime import datetime
from typing import Optional, List
import uuid
from sqlalchemy import Column, DateTime, Text, JSON
from sqlmodel import SQLModel, Field
from utils.datetime_utils import get_current_utc_datetime
class PresentationLayoutCodeModel(SQLModel, table=True):
"""Model for storing presentation layout codes"""
__tablename__ = "presentation_layout_codes"
id: Optional[int] = Field(default=None, primary_key=True)
presentation: uuid.UUID = Field(index=True, description="UUID of the presentation")
layout_id: str = Field(description="Unique identifier for the layout")
layout_name: str = Field(description="Display name of the layout")
layout_code: str = Field(
sa_column=Column(Text), description="TSX/React component code for the layout"
)
fonts: Optional[List[str]] = Field(
sa_column=Column(JSON), default=None, description="Optional list of font links"
)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
)
)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
nullable=False,
default=get_current_utc_datetime,
onupdate=get_current_utc_datetime,
),
)

View file

@ -0,0 +1,36 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import DateTime, ForeignKey
from sqlmodel import Field, Column, JSON, SQLModel
class SlideModel(SQLModel, table=True):
__tablename__ = "slides"
id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4)
presentation: uuid.UUID = Field(
sa_column=Column(ForeignKey("presentations.id", ondelete="CASCADE"), index=True)
)
layout_group: str
layout: str
index: int
content: dict = Field(sa_column=Column(JSON))
html_content: Optional[str]
speaker_note: Optional[str] = None
properties: Optional[dict] = Field(sa_column=Column(JSON))
deleted_at: Optional[datetime] = Field(
sa_column=Column(DateTime(timezone=True), nullable=True), default=None
)
def get_new_slide(self, presentation: uuid.UUID, content: Optional[dict] = None):
return SlideModel(
id=uuid.uuid4(),
presentation=presentation,
layout_group=self.layout_group,
layout=self.layout,
index=self.index,
speaker_note=self.speaker_note,
content=content or self.content,
properties=self.properties,
)

View file

@ -0,0 +1,22 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, Boolean
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class TeamModel(SQLModel, table=True):
__tablename__ = "teams"
id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4)
name: str
client_id: Optional[uuid.UUID] = Field(
sa_column=Column(ForeignKey("clients.id"), nullable=True), default=None
)
is_default: bool = Field(sa_column=Column(Boolean, default=False, nullable=False))
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
),
)

View file

@ -0,0 +1,24 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, UniqueConstraint
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class TeamMembershipModel(SQLModel, table=True):
__tablename__ = "team_memberships"
__table_args__ = (UniqueConstraint("user_id", "team_id"),)
id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4)
user_id: uuid.UUID = Field(sa_column=Column(ForeignKey("users.id"), index=True))
team_id: uuid.UUID = Field(sa_column=Column(ForeignKey("teams.id"), index=True))
assigned_by: Optional[uuid.UUID] = Field(
sa_column=Column(ForeignKey("users.id", name="fk_assigned_by"), nullable=True),
default=None,
)
assigned_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
),
)

View file

@ -0,0 +1,26 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import Column, DateTime
from sqlmodel import SQLModel, Field
from utils.datetime_utils import get_current_utc_datetime
class TemplateModel(SQLModel, table=True):
__tablename__ = "templates"
id: uuid.UUID = Field(
default_factory=uuid.uuid4,
primary_key=True,
description="UUID for the template (matches presentation_id)",
)
name: str = Field(description="Human friendly template name")
description: Optional[str] = Field(
default=None, description="Optional template description"
)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
),
)

View file

@ -0,0 +1,37 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import Column, DateTime, String, Boolean
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class UserModel(SQLModel, table=True):
__tablename__ = "users"
id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4)
azure_oid: Optional[str] = Field(
sa_column=Column(String, unique=True, index=True, nullable=True),
default=None,
)
email: str = Field(sa_column=Column(String, unique=True, index=True))
display_name: str
role: str = Field(default="user") # super_admin, client_admin, user
is_active: bool = Field(sa_column=Column(Boolean, default=True, nullable=False))
last_login_at: Optional[datetime] = Field(
sa_column=Column(DateTime(timezone=True), nullable=True),
default=None,
)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
),
)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
nullable=False,
default=get_current_utc_datetime,
onupdate=get_current_utc_datetime,
),
)

View file

@ -0,0 +1,22 @@
import secrets
from typing import Optional
import uuid
from datetime import datetime
from sqlmodel import Column, DateTime, Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class WebhookSubscription(SQLModel, table=True):
__tablename__ = "webhook_subscriptions"
id: str = Field(
default_factory=lambda: f"webhook-{secrets.token_hex(32)}", primary_key=True
)
created_at: datetime = Field(
sa_column=Column(DateTime(timezone=True), nullable=False),
default_factory=get_current_utc_datetime,
)
url: str
secret: Optional[str] = None
event: str = Field(index=True)

View file

@ -0,0 +1,40 @@
import json
from pydantic import BaseModel
class SSEResponse(BaseModel):
event: str
data: str
def to_string(self):
return f"event: {self.event}\ndata: {self.data}\n\n"
class SSEStatusResponse(BaseModel):
status: str
def to_string(self):
return SSEResponse(
event="response", data=json.dumps({"type": "status", "status": self.status})
).to_string()
class SSEErrorResponse(BaseModel):
detail: str
def to_string(self):
return SSEResponse(
event="response", data=json.dumps({"type": "error", "detail": self.detail})
).to_string()
class SSECompleteResponse(BaseModel):
key: str
value: object
def to_string(self):
return SSEResponse(
event="response",
data=json.dumps({"type": "complete", self.key: self.value}),
).to_string()

View file

@ -0,0 +1,50 @@
from typing import Optional
from pydantic import BaseModel
class UserConfig(BaseModel):
LLM: Optional[str] = None
# OpenAI
OPENAI_API_KEY: Optional[str] = None
OPENAI_MODEL: Optional[str] = None
# Google
GOOGLE_API_KEY: Optional[str] = None
GOOGLE_MODEL: Optional[str] = None
# Anthropic
ANTHROPIC_API_KEY: Optional[str] = None
ANTHROPIC_MODEL: Optional[str] = None
# Ollama
OLLAMA_URL: Optional[str] = None
OLLAMA_MODEL: Optional[str] = None
# Custom LLM
CUSTOM_LLM_URL: Optional[str] = None
CUSTOM_LLM_API_KEY: Optional[str] = None
CUSTOM_MODEL: Optional[str] = None
# Image Provider
DISABLE_IMAGE_GENERATION: Optional[bool] = None
IMAGE_PROVIDER: Optional[str] = None
PEXELS_API_KEY: Optional[str] = None
PIXABAY_API_KEY: Optional[str] = None
# ComfyUI
COMFYUI_URL: Optional[str] = None
COMFYUI_WORKFLOW: Optional[str] = None
# Dalle 3 Quality
DALL_E_3_QUALITY: Optional[str] = None
# Gpt Image 1.5 Quality
GPT_IMAGE_1_5_QUALITY: Optional[str] = None
# Reasoning
TOOL_CALLS: Optional[bool] = None
DISABLE_THINKING: Optional[bool] = None
EXTENDED_REASONING: Optional[bool] = None
# Web Search
WEB_GROUNDING: Optional[bool] = None

294
backend/openai_spec.json Normal file
View file

@ -0,0 +1,294 @@
{
"openapi": "3.1.0",
"info": {
"title": "FastAPI",
"version": "0.1.0"
},
"paths": {
"/api/v1/ppt/presentation/generate": {
"post": {
"tags": ["Presentation"],
"summary": "Returns base URL of generated presentation's PDF or PPTX.",
"operationId": "generate_presentation",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_generate_presentation_api_api_v1_ppt_presentation_generate_post"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PresentationPathAndEditPath"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/ppt/template-management/summary": {
"get": {
"tags": ["template-management"],
"summary": "Get all presentations with layout counts",
"description": "Returns a list of extra custom templates available for presenation creation.",
"operationId": "templates_list",
"responses": {
"200": {
"description": "Presentations summary retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetPresentationSummaryResponse"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Body_generate_presentation_api_api_v1_ppt_presentation_generate_post": {
"properties": {
"content": {
"type": "string",
"title": "Content",
"description": "The content for generating the presentation"
},
"n_slides": {
"type": "integer",
"title": "N Slides",
"default": 8
},
"language": {
"type": "string",
"title": "Language",
"default": "English"
},
"template": {
"type": "string",
"title": "Template",
"default": "general"
},
"files": {
"anyOf": [
{
"items": {
"type": "string",
"format": "binary"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Files"
},
"export_as": {
"type": "string",
"enum": ["pptx", "pdf"],
"title": "Export As",
"default": "pptx"
}
},
"type": "object",
"required": ["content"],
"title": "Body_generate_presentation_api_api_v1_ppt_presentation_generate_post"
},
"PresentationPathAndEditPath": {
"properties": {
"presentation_id": {
"type": "string",
"title": "Presentation Id"
},
"path": {
"type": "string",
"title": "Path"
},
"edit_path": {
"type": "string",
"title": "Edit Path"
}
},
"type": "object",
"required": ["presentation_id", "path", "edit_path"],
"title": "PresentationPathAndEditPath"
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"ValidationError": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": ["loc", "msg", "type"],
"title": "ValidationError"
},
"GetPresentationSummaryResponse": {
"properties": {
"success": {
"type": "boolean",
"title": "Success"
},
"presentations": {
"items": {
"$ref": "#/components/schemas/PresentationSummary"
},
"type": "array",
"title": "Presentations"
},
"total_presentations": {
"type": "integer",
"title": "Total Presentations"
},
"total_layouts": {
"type": "integer",
"title": "Total Layouts"
},
"message": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Message"
}
},
"type": "object",
"required": ["success", "presentations", "total_presentations", "total_layouts"],
"title": "GetPresentationSummaryResponse"
},
"PresentationSummary": {
"properties": {
"presentation_id": {
"type": "string",
"title": "Presentation Id"
},
"layout_count": {
"type": "integer",
"title": "Layout Count"
},
"last_updated_at": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Last Updated At"
},
"template": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Template"
}
},
"type": "object",
"required": ["presentation_id", "layout_count"],
"title": "PresentationSummary"
},
"ErrorResponse": {
"properties": {
"success": {
"type": "boolean",
"title": "Success",
"default": false
},
"detail": {
"type": "string",
"title": "Detail"
},
"error_code": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Error Code"
}
},
"type": "object",
"required": ["detail"],
"title": "ErrorResponse"
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more