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:
parent
26d999314e
commit
cf21ba4516
2068 changed files with 150455 additions and 0 deletions
53
.env.example
Normal file
53
.env.example
Normal 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
34
Makefile
Normal 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
1
backend/.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.11
|
||||
32
backend/Dockerfile
Normal file
32
backend/Dockerfile
Normal 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
37
backend/alembic.ini
Normal 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
0
backend/api/__init__.py
Normal file
23
backend/api/lifespan.py
Normal file
23
backend/api/lifespan.py
Normal 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
55
backend/api/main.py
Normal 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)
|
||||
12
backend/api/middlewares.py
Normal file
12
backend/api/middlewares.py
Normal 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)
|
||||
0
backend/api/middlewares/__init__.py
Normal file
0
backend/api/middlewares/__init__.py
Normal file
62
backend/api/middlewares/audit_middleware.py
Normal file
62
backend/api/middlewares/audit_middleware.py
Normal 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
|
||||
82
backend/api/middlewares/auth_middleware.py
Normal file
82
backend/api/middlewares/auth_middleware.py
Normal 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)
|
||||
32
backend/api/middlewares/rbac_middleware.py
Normal file
32
backend/api/middlewares/rbac_middleware.py
Normal 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")
|
||||
0
backend/api/v1/admin/__init__.py
Normal file
0
backend/api/v1/admin/__init__.py
Normal file
109
backend/api/v1/admin/audit_router.py
Normal file
109
backend/api/v1/admin/audit_router.py
Normal 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}"},
|
||||
)
|
||||
199
backend/api/v1/admin/brand_config_router.py
Normal file
199
backend/api/v1/admin/brand_config_router.py
Normal 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}
|
||||
154
backend/api/v1/admin/clients_router.py
Normal file
154
backend/api/v1/admin/clients_router.py
Normal 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)}
|
||||
262
backend/api/v1/admin/master_decks_router.py
Normal file
262
backend/api/v1/admin/master_decks_router.py
Normal 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}
|
||||
204
backend/api/v1/admin/teams_router.py
Normal file
204
backend/api/v1/admin/teams_router.py
Normal 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"}
|
||||
112
backend/api/v1/admin/users_router.py
Normal file
112
backend/api/v1/admin/users_router.py
Normal 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)}
|
||||
0
backend/api/v1/auth/__init__.py
Normal file
0
backend/api/v1/auth/__init__.py
Normal file
134
backend/api/v1/auth/router.py
Normal file
134
backend/api/v1/auth/router.py
Normal 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
|
||||
34
backend/api/v1/mock/router.py
Normal file
34
backend/api/v1/mock/router.py
Normal 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",
|
||||
)
|
||||
]
|
||||
73
backend/api/v1/ppt/background_tasks.py
Normal file
73
backend/api/v1/ppt/background_tasks.py
Normal 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()
|
||||
0
backend/api/v1/ppt/endpoints/__init__.py
Normal file
0
backend/api/v1/ppt/endpoints/__init__.py
Normal file
16
backend/api/v1/ppt/endpoints/anthropic.py
Normal file
16
backend/api/v1/ppt/endpoints/anthropic.py
Normal 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))
|
||||
87
backend/api/v1/ppt/endpoints/files.py
Normal file
87
backend/api/v1/ppt/endpoints/files.py
Normal 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"}
|
||||
290
backend/api/v1/ppt/endpoints/fonts.py
Normal file
290
backend/api/v1/ppt/endpoints/fonts.py
Normal 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)}"
|
||||
)
|
||||
14
backend/api/v1/ppt/endpoints/google.py
Normal file
14
backend/api/v1/ppt/endpoints/google.py
Normal 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))
|
||||
10
backend/api/v1/ppt/endpoints/icons.py
Normal file
10
backend/api/v1/ppt/endpoints/icons.py
Normal 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)
|
||||
105
backend/api/v1/ppt/endpoints/images.py
Normal file
105
backend/api/v1/ppt/endpoints/images.py
Normal 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)}")
|
||||
27
backend/api/v1/ppt/endpoints/layouts.py
Normal file
27
backend/api/v1/ppt/endpoints/layouts.py
Normal 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)
|
||||
85
backend/api/v1/ppt/endpoints/ollama.py
Normal file
85
backend/api/v1/ppt/endpoints/ollama.py
Normal 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,
|
||||
)
|
||||
17
backend/api/v1/ppt/endpoints/openai.py
Normal file
17
backend/api/v1/ppt/endpoints/openai.py
Normal 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))
|
||||
113
backend/api/v1/ppt/endpoints/outlines.py
Normal file
113
backend/api/v1/ppt/endpoints/outlines.py
Normal 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")
|
||||
116
backend/api/v1/ppt/endpoints/pdf_slides.py
Normal file
116
backend/api/v1/ppt/endpoints/pdf_slides.py
Normal 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)}"
|
||||
)
|
||||
613
backend/api/v1/ppt/endpoints/pptx_slides.py
Normal file
613
backend/api/v1/ppt/endpoints/pptx_slides.py
Normal 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)}")
|
||||
973
backend/api/v1/ppt/endpoints/presentation.py
Normal file
973
backend/api/v1/ppt/endpoints/presentation.py
Normal 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}",
|
||||
)
|
||||
241
backend/api/v1/ppt/endpoints/prompts.py
Normal file
241
backend/api/v1/ppt/endpoints/prompts.py
Normal file
File diff suppressed because one or more lines are too long
90
backend/api/v1/ppt/endpoints/slide.py
Normal file
90
backend/api/v1/ppt/endpoints/slide.py
Normal 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
|
||||
1043
backend/api/v1/ppt/endpoints/slide_to_html.py
Normal file
1043
backend/api/v1/ppt/endpoints/slide_to_html.py
Normal file
File diff suppressed because it is too large
Load diff
39
backend/api/v1/ppt/router.py
Normal file
39
backend/api/v1/ppt/router.py
Normal 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)
|
||||
53
backend/api/v1/webhook/router.py
Normal file
53
backend/api/v1/webhook/router.py
Normal 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
63510
backend/assets/icons.json
Normal file
File diff suppressed because it is too large
Load diff
0
backend/constants/__init__.py
Normal file
0
backend/constants/__init__.py
Normal file
20
backend/constants/documents.py
Normal file
20
backend/constants/documents.py
Normal 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
6
backend/constants/llm.py
Normal 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"
|
||||
1
backend/constants/presentation.py
Normal file
1
backend/constants/presentation.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift"]
|
||||
180
backend/constants/supported_ollama_models.py
Normal file
180
backend/constants/supported_ollama_models.py
Normal 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,
|
||||
}
|
||||
0
backend/enums/__init__.py
Normal file
0
backend/enums/__init__.py
Normal file
11
backend/enums/image_provider.py
Normal file
11
backend/enums/image_provider.py
Normal 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"
|
||||
8
backend/enums/llm_call_type.py
Normal file
8
backend/enums/llm_call_type.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class LLMCallType(Enum):
|
||||
UNSTRUCTURED = "unstructured"
|
||||
UNSTRUCTURED_STREAM = "unstructured_stream"
|
||||
STRUCTURED = "structured"
|
||||
STRUCTURED_STREAM = "structured_stream"
|
||||
9
backend/enums/llm_provider.py
Normal file
9
backend/enums/llm_provider.py
Normal 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
11
backend/enums/tone.py
Normal 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"
|
||||
|
||||
8
backend/enums/verbosity.py
Normal file
8
backend/enums/verbosity.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class Verbosity(str, Enum):
|
||||
CONCISE = "concise"
|
||||
STANDARD = "standard"
|
||||
TEXT_HEAVY = "text-heavy"
|
||||
|
||||
6
backend/enums/webhook_event.py
Normal file
6
backend/enums/webhook_event.py
Normal 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
68
backend/mcp_server.py
Normal 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)
|
||||
0
backend/migrations/__init__.py
Normal file
0
backend/migrations/__init__.py
Normal file
75
backend/migrations/env.py
Normal file
75
backend/migrations/env.py
Normal 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())
|
||||
27
backend/migrations/script.py.mako
Normal file
27
backend/migrations/script.py.mako
Normal 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"}
|
||||
0
backend/models/__init__.py
Normal file
0
backend/models/__init__.py
Normal file
13
backend/models/api_error_model.py
Normal file
13
backend/models/api_error_model.py
Normal 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))
|
||||
6
backend/models/decomposed_file_info.py
Normal file
6
backend/models/decomposed_file_info.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DecomposedFileInfo(BaseModel):
|
||||
name: str
|
||||
file_path: str
|
||||
13
backend/models/document_chunk.py
Normal file
13
backend/models/document_chunk.py
Normal 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}")
|
||||
42
backend/models/generate_presentation_request.py
Normal file
42
backend/models/generate_presentation_request.py
Normal 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"
|
||||
)
|
||||
10
backend/models/image_prompt.py
Normal file
10
backend/models/image_prompt.py
Normal 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
|
||||
14
backend/models/json_path_guide.py
Normal file
14
backend/models/json_path_guide.py
Normal 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]
|
||||
59
backend/models/llm_message.py
Normal file
59
backend/models/llm_message.py
Normal 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
|
||||
30
backend/models/llm_tool_call.py
Normal file
30
backend/models/llm_tool_call.py
Normal 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
|
||||
29
backend/models/llm_tools.py
Normal file
29
backend/models/llm_tools.py
Normal 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
|
||||
7
backend/models/ollama_model_metadata.py
Normal file
7
backend/models/ollama_model_metadata.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OllamaModelMetadata(BaseModel):
|
||||
label: str
|
||||
value: str
|
||||
size: str
|
||||
10
backend/models/ollama_model_status.py
Normal file
10
backend/models/ollama_model_status.py
Normal 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
|
||||
171
backend/models/pptx_models.py
Normal file
171
backend/models/pptx_models.py
Normal 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]
|
||||
11
backend/models/presentation_and_path.py
Normal file
11
backend/models/presentation_and_path.py
Normal 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
|
||||
14
backend/models/presentation_from_template.py
Normal file
14
backend/models/presentation_from_template.py
Normal 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"
|
||||
39
backend/models/presentation_layout.py
Normal file
39
backend/models/presentation_layout.py
Normal 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
|
||||
17
backend/models/presentation_outline_model.py
Normal file
17
backend/models/presentation_outline_model.py
Normal 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
|
||||
6
backend/models/presentation_structure_model.py
Normal file
6
backend/models/presentation_structure_model.py
Normal 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")
|
||||
20
backend/models/presentation_with_slides.py
Normal file
20
backend/models/presentation_with_slides.py
Normal 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]
|
||||
5
backend/models/slide_layout_index.py
Normal file
5
backend/models/slide_layout_index.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SlideLayoutIndex(BaseModel):
|
||||
index: int
|
||||
22
backend/models/sql/async_presentation_generation_status.py
Normal file
22
backend/models/sql/async_presentation_generation_status.py
Normal 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)
|
||||
31
backend/models/sql/audit_log.py
Normal file
31
backend/models/sql/audit_log.py
Normal 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,
|
||||
),
|
||||
)
|
||||
39
backend/models/sql/brand_config.py
Normal file
39
backend/models/sql/brand_config.py
Normal 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,
|
||||
),
|
||||
)
|
||||
35
backend/models/sql/client.py
Normal file
35
backend/models/sql/client.py
Normal 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,
|
||||
),
|
||||
)
|
||||
20
backend/models/sql/image_asset.py
Normal file
20
backend/models/sql/image_asset.py
Normal 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
37
backend/models/sql/job.py
Normal 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
|
||||
)
|
||||
8
backend/models/sql/key_value.py
Normal file
8
backend/models/sql/key_value.py
Normal 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))
|
||||
38
backend/models/sql/master_deck.py
Normal file
38
backend/models/sql/master_deck.py
Normal 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,
|
||||
),
|
||||
)
|
||||
9
backend/models/sql/ollama_pull_status.py
Normal file
9
backend/models/sql/ollama_pull_status.py
Normal 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))
|
||||
102
backend/models/sql/presentation.py
Normal file
102
backend/models/sql/presentation.py
Normal 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()
|
||||
37
backend/models/sql/presentation_layout_code.py
Normal file
37
backend/models/sql/presentation_layout_code.py
Normal 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,
|
||||
),
|
||||
)
|
||||
36
backend/models/sql/slide.py
Normal file
36
backend/models/sql/slide.py
Normal 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,
|
||||
)
|
||||
22
backend/models/sql/team.py
Normal file
22
backend/models/sql/team.py
Normal 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
|
||||
),
|
||||
)
|
||||
24
backend/models/sql/team_membership.py
Normal file
24
backend/models/sql/team_membership.py
Normal 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
|
||||
),
|
||||
)
|
||||
26
backend/models/sql/template.py
Normal file
26
backend/models/sql/template.py
Normal 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
|
||||
),
|
||||
)
|
||||
37
backend/models/sql/user.py
Normal file
37
backend/models/sql/user.py
Normal 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,
|
||||
),
|
||||
)
|
||||
22
backend/models/sql/webhook_subscription.py
Normal file
22
backend/models/sql/webhook_subscription.py
Normal 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)
|
||||
40
backend/models/sse_response.py
Normal file
40
backend/models/sse_response.py
Normal 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()
|
||||
50
backend/models/user_config.py
Normal file
50
backend/models/user_config.py
Normal 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
294
backend/openai_spec.json
Normal 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
Loading…
Add table
Reference in a new issue