ppt-tool/backend/api/v1/ppt/endpoints/slide_to_html.py
Vadym Samoilenko ae41562103 Phase 8: Data-driven slide architecture + template management overhaul
Replaces TSX/Babel compilation pipeline with a JSON element model:
- New _do_parse_v2(): 1 LLM call/layout (vs 2) classifies OXML geometry
  elements into placeholder types → JSON stored in layout_code
- SlideRenderer.tsx: renders JSON element model as %-positioned divs,
  no Babel compilation or runtime errors
- parseLayoutSchema.ts: isJsonLayoutCode() / parseLayoutSchema() /
  mergeElementsWithContent() — full JSON schema parsing layer
- useCustomTemplates.ts: transparent dual-format support (JSON + TSX)
  via parsedLayoutToCompiled() adapter

Template management improvements:
- PresentationLayoutCodeModel: +is_enabled (bool) +thumbnail_path (str)
- Migration 005: adds both columns to presentation_layout_codes
- DELETE /master-decks/{id}: hard delete (files + TemplateModel +
  PresentationLayoutCodeModel rows + MasterDeckModel)
- PATCH /template-management/layouts/{db_id}/toggle-enabled: new endpoint
- LayoutData response: +db_id, +is_enabled, +thumbnail_path
- _register_as_template(): stores thumbnail_path + is_enabled per layout

Admin UI:
- /admin/templates/ — list all custom templates with delete
- /admin/templates/[id]/ — layout grid with screenshots + enable/disable
- AdminSidebar: Templates nav item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 20:05:25 +00:00

878 lines
31 KiB
Python

import os
import base64
from datetime import datetime
from typing import Optional, List, Dict
from uuid import UUID
from fastapi import APIRouter, HTTPException, File, UploadFile, Form, Depends
from pydantic import BaseModel
from openai import OpenAI
from openai import APIError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
from utils.asset_directory_utils import get_images_directory
from services.database import get_async_session
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from services.llm_service import UnifiedLLMService
from .prompts import (
GENERATE_HTML_SYSTEM_PROMPT,
HTML_TO_REACT_SYSTEM_PROMPT,
HTML_EDIT_SYSTEM_PROMPT,
)
from utils.oxml_geometry import extract_geometry_from_oxml, format_geometry_for_llm
from utils.tsx_validator import validate_tsx_syntax
from models.sql.template import TemplateModel
# Create separate routers for each functionality
SLIDE_TO_HTML_ROUTER = APIRouter(prefix="/slide-to-html", tags=["slide-to-html"])
HTML_TO_REACT_ROUTER = APIRouter(prefix="/html-to-react", tags=["html-to-react"])
HTML_EDIT_ROUTER = APIRouter(prefix="/html-edit", tags=["html-edit"])
LAYOUT_MANAGEMENT_ROUTER = APIRouter(
prefix="/template-management", tags=["template-management"]
)
# Request/Response models for slide-to-html endpoint
class SlideToHtmlRequest(BaseModel):
image: str # Partial path to image file (e.g., "/app_data/images/uuid/slide_1.png")
xml: str # OXML content as text
fonts: Optional[List[str]] = None # Optional normalized root fonts for this slide
class SlideToHtmlResponse(BaseModel):
success: bool
html: str
# Request/Response models for html-edit endpoint
class HtmlEditResponse(BaseModel):
success: bool
edited_html: str
message: Optional[str] = None
# Request/Response models for html-to-react endpoint
class HtmlToReactRequest(BaseModel):
html: str # HTML content to convert to React component
image: Optional[str] = None # Optional image path to provide visual context
class HtmlToReactResponse(BaseModel):
success: bool
react_component: str
message: Optional[str] = None
# Request/Response models for layout management endpoints
class LayoutData(BaseModel):
presentation: UUID # UUID of the presentation
layout_id: str # Unique identifier for the layout
layout_name: str # Display name of the layout
layout_code: str # TSX/React component code for the layout
fonts: Optional[List[str]] = None # Optional list of font links
db_id: Optional[int] = None # new: DB primary key for toggle
is_enabled: Optional[bool] = True # new
thumbnail_path: Optional[str] = None # new
class SaveLayoutsRequest(BaseModel):
layouts: list[LayoutData]
class SaveLayoutsResponse(BaseModel):
success: bool
saved_count: int
message: Optional[str] = None
class GetLayoutsResponse(BaseModel):
success: bool
layouts: list[LayoutData]
message: Optional[str] = None
template: Optional[dict] = None
fonts: Optional[List[str]] = None
class PresentationSummary(BaseModel):
presentation_id: UUID
layout_count: int
last_updated_at: Optional[datetime] = None
template: Optional[dict] = None
class GetPresentationSummaryResponse(BaseModel):
success: bool
presentations: List[PresentationSummary]
total_presentations: int
total_layouts: int
message: Optional[str] = None
class ErrorResponse(BaseModel):
success: bool = False
detail: str
error_code: Optional[str] = None
class TemplateCreateRequest(BaseModel):
id: UUID
name: str
description: Optional[str] = None
class TemplateCreateResponse(BaseModel):
success: bool
template: dict
message: Optional[str] = None
class TemplateInfo(BaseModel):
id: UUID
name: Optional[str] = None
description: Optional[str] = None
created_at: Optional[datetime] = None
async def generate_html_from_slide(
base64_image: str,
media_type: str,
xml_content: str,
api_key: str, # Kept for signature compatibility, though LLM service uses env by default
fonts: Optional[List[str]] = None,
) -> str:
"""Generate HTML content from slide image and XML using active LLM Service."""
print(f"Generating HTML from slide image and XML using Unified LLM Service...")
try:
# Extract geometric coordinates from XML to JSON
geometric_elements = extract_geometry_from_oxml(xml_content)
geometry_json = format_geometry_for_llm(geometric_elements)
fonts_text = (
f"\nFONTS (Normalized root families used in this slide, use where it is required): {', '.join(fonts)}"
if fonts else ""
)
user_text = f"Slide Design Extracted Elements (JSON): \n{geometry_json}\n\n{fonts_text}"
html_content = await UnifiedLLMService.generate_vision_completion(
system_prompt=GENERATE_HTML_SYSTEM_PROMPT,
user_text=user_text,
image_base64=base64_image,
media_type=media_type
)
return UnifiedLLMService.clean_llm_code_output(html_content, ["html"])
except Exception as e:
print(f"Error occurred during HTML generation: {str(e)}")
raise HTTPException(
status_code=500, detail=f"LLM API error during HTML generation: {str(e)}"
)
async def generate_react_component_from_html(
html_content: str,
api_key: str, # Kept for signature compatibility
image_base64: Optional[str] = None,
media_type: Optional[str] = None,
) -> str:
"""Convert HTML content to TSX React component using Active LLM Service."""
try:
user_text = f"HTML INPUT:\n{html_content}"
react_content = await UnifiedLLMService.generate_vision_completion(
system_prompt=HTML_TO_REACT_SYSTEM_PROMPT,
user_text=user_text,
image_base64=image_base64 or "",
media_type=media_type or "image/png"
)
react_content = UnifiedLLMService.clean_llm_code_output(react_content, ["tsx", "typescript", "javascript"])
# Filter out lines that start with import or export
filtered_lines = []
for line in react_content.split("\n"):
stripped_line = line.strip()
if not (stripped_line.startswith("import ") or stripped_line.startswith("export ")):
filtered_lines.append(line)
return "\n".join(filtered_lines)
except Exception as e:
print(f"Error occurred: {str(e)}")
raise HTTPException(
status_code=500, detail=f"LLM API error during React generation: {str(e)}"
)
async def edit_html_with_images(
current_ui_base64: str,
sketch_base64: Optional[str],
media_type: str,
html_content: str,
prompt: str,
api_key: str, # Kept for signature compatibility
) -> str:
"""Edit HTML content based on images and text prompt using Active LLM Service."""
try:
user_text = f"CURRENT HTML TO EDIT:\n{html_content}\n\nTEXT PROMPT FOR CHANGES:\n{prompt}"
# If there's a sketch image, we'd ideally pass both, but UnifiedLLMService
# API currently takes one primary image. We'll pass the sketch if it exists,
# otherwise the current UI, to keep the abstraction clean.
# (Future improvement: update UnifiedLLMService to accept multiple images)
primary_image = sketch_base64 if sketch_base64 else current_ui_base64
edited_html = await UnifiedLLMService.generate_vision_completion(
system_prompt=HTML_EDIT_SYSTEM_PROMPT,
user_text=user_text,
image_base64=primary_image,
media_type=media_type
)
return UnifiedLLMService.clean_llm_code_output(edited_html, ["html"])
except Exception as e:
print(f"Error occurred during HTML editing: {str(e)}")
raise HTTPException(
status_code=500, detail=f"LLM API error during HTML editing: {str(e)}"
)
# ENDPOINT 1: Slide to HTML conversion
@SLIDE_TO_HTML_ROUTER.post("/", response_model=SlideToHtmlResponse)
async def convert_slide_to_html(request: SlideToHtmlRequest):
"""
Convert a slide image and its OXML data to HTML using Anthropic Claude API.
Args:
request: JSON request containing image path and XML content
Returns:
SlideToHtmlResponse with generated HTML
"""
try:
# Get OpenAI API key from environment
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise HTTPException(
status_code=500, detail="OPENAI_API_KEY environment variable not set"
)
# Resolve image path to actual file system path
image_path = request.image
# Handle different path formats
if image_path.startswith("/app_data/images/"):
# Remove the /app_data/images/ prefix and join with actual images directory
relative_path = image_path[len("/app_data/images/") :]
actual_image_path = os.path.join(get_images_directory(), relative_path)
elif image_path.startswith("/static/"):
# Handle static files
relative_path = image_path[len("/static/") :]
actual_image_path = os.path.join("static", relative_path)
else:
# Assume it's already a full path or relative to images directory
if os.path.isabs(image_path):
actual_image_path = image_path
else:
actual_image_path = os.path.join(get_images_directory(), image_path)
# Check if image file exists
if not os.path.exists(actual_image_path):
raise HTTPException(
status_code=404, detail=f"Image file not found: {image_path}"
)
# Read and encode image to base64
with open(actual_image_path, "rb") as image_file:
image_content = image_file.read()
base64_image = base64.b64encode(image_content).decode("utf-8")
# Determine media type from file extension
file_extension = os.path.splitext(actual_image_path)[1].lower()
media_type_map = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
media_type = media_type_map.get(file_extension, "image/png")
# Generate HTML using the extracted function
html_content = await generate_html_from_slide(
base64_image=base64_image,
media_type=media_type,
xml_content=request.xml,
api_key=api_key,
fonts=request.fonts,
)
html_content = html_content.replace("```html", "").replace("```", "")
return SlideToHtmlResponse(success=True, html=html_content)
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
# Log the full error for debugging
print(f"Unexpected error during slide to HTML processing: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error processing slide to HTML: {str(e)}"
)
# ENDPOINT 2: HTML to React component conversion
@HTML_TO_REACT_ROUTER.post("/", response_model=HtmlToReactResponse)
async def convert_html_to_react(request: HtmlToReactRequest):
"""
Convert HTML content to TSX React component using Anthropic Claude API.
Args:
request: JSON request containing HTML content
Returns:
HtmlToReactResponse with generated React component
"""
try:
# Get OpenAI API key from environment
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise HTTPException(
status_code=500, detail="OPENAI_API_KEY environment variable not set"
)
# Validate HTML content
if not request.html or not request.html.strip():
raise HTTPException(status_code=400, detail="HTML content cannot be empty")
# Optionally resolve image and encode to base64
image_b64 = None
media_type = None
if request.image:
image_path = request.image
if image_path.startswith("/app_data/images/"):
relative_path = image_path[len("/app_data/images/") :]
actual_image_path = os.path.join(get_images_directory(), relative_path)
elif image_path.startswith("/static/"):
relative_path = image_path[len("/static/") :]
actual_image_path = os.path.join("static", relative_path)
else:
actual_image_path = (
image_path
if os.path.isabs(image_path)
else os.path.join(get_images_directory(), image_path)
)
if os.path.exists(actual_image_path):
with open(actual_image_path, "rb") as f:
image_b64 = base64.b64encode(f.read()).decode("utf-8")
ext = os.path.splitext(actual_image_path)[1].lower()
media_type = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}.get(ext, "image/png")
# Convert HTML to React component
react_component = await generate_react_component_from_html(
html_content=request.html,
api_key=api_key,
image_base64=image_b64,
media_type=media_type,
)
react_component = react_component.replace("```tsx", "").replace("```", "")
# Verify basic TSX syntax to catch massive hallucinations
if not validate_tsx_syntax(react_component):
raise HTTPException(
status_code=500, detail="Generated React component failed syntax validation (possible hallucination or truncation)"
)
return HtmlToReactResponse(
success=True,
react_component=react_component,
message="React component generated successfully",
)
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
# Log the full error for debugging
print(f"Unexpected error during HTML to React processing: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error processing HTML to React: {str(e)}"
)
# ENDPOINT 3: HTML editing with images
@HTML_EDIT_ROUTER.post("/", response_model=HtmlEditResponse)
async def edit_html_with_images_endpoint(
current_ui_image: UploadFile = File(..., description="Current UI image file"),
sketch_image: Optional[UploadFile] = File(
None, description="Sketch/indication image file (optional)"
),
html: str = Form(..., description="Current HTML content to edit"),
prompt: str = Form(..., description="Text prompt describing the changes"),
):
"""
Edit HTML content based on one or two uploaded images and a text prompt using Anthropic Claude API.
Args:
current_ui_image: Uploaded current UI image file
sketch_image: Uploaded sketch/indication image file (optional)
html: Current HTML content to edit (form data)
prompt: Text prompt describing the changes (form data)
Returns:
HtmlEditResponse with edited HTML
"""
try:
# Get OpenAI API key from environment
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise HTTPException(
status_code=500, detail="OPENAI_API_KEY environment variable not set"
)
# Validate inputs
if not html or not html.strip():
raise HTTPException(status_code=400, detail="HTML content cannot be empty")
if not prompt or not prompt.strip():
raise HTTPException(status_code=400, detail="Text prompt cannot be empty")
# Validate current UI image file
if (
not current_ui_image.content_type
or not current_ui_image.content_type.startswith("image/")
):
raise HTTPException(
status_code=400, detail="Current UI file must be an image"
)
# Validate sketch image file only if provided
if sketch_image and (
not sketch_image.content_type
or not sketch_image.content_type.startswith("image/")
):
raise HTTPException(status_code=400, detail="Sketch file must be an image")
# Read and encode current UI image to base64
current_ui_content = await current_ui_image.read()
current_ui_base64 = base64.b64encode(current_ui_content).decode("utf-8")
# Read and encode sketch image to base64 only if provided
sketch_base64 = None
if sketch_image:
sketch_content = await sketch_image.read()
sketch_base64 = base64.b64encode(sketch_content).decode("utf-8")
# Use the content type from the uploaded files
media_type = current_ui_image.content_type
# Edit HTML using the function
edited_html = await edit_html_with_images(
current_ui_base64=current_ui_base64,
sketch_base64=sketch_base64,
media_type=media_type,
html_content=html,
prompt=prompt,
api_key=api_key,
)
edited_html = edited_html.replace("```html", "").replace("```", "")
return HtmlEditResponse(
success=True, edited_html=edited_html, message="HTML edited successfully"
)
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
# Log the full error for debugging
print(f"Unexpected error during HTML editing: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error processing HTML editing: {str(e)}"
)
# ENDPOINT 4: Save layouts for a presentation
@LAYOUT_MANAGEMENT_ROUTER.post(
"/save-templates",
response_model=SaveLayoutsResponse,
responses={
400: {"model": ErrorResponse, "description": "Validation error"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def save_layouts(
request: SaveLayoutsRequest, session: AsyncSession = Depends(get_async_session)
):
"""
Save multiple layouts for presentations.
Args:
request: JSON request containing array of layout data
session: Database session
Returns:
SaveLayoutsResponse with success status and count of saved layouts
Raises:
HTTPException: 400 for validation errors, 500 for server errors
"""
try:
# Validate request data
if not request.layouts:
raise HTTPException(status_code=400, detail="Layouts array cannot be empty")
if len(request.layouts) > 50: # Reasonable limit
raise HTTPException(
status_code=400, detail="Cannot save more than 50 layouts at once"
)
saved_count = 0
for i, layout_data in enumerate(request.layouts):
# Validate individual layout data
if (
not layout_data.presentation
or not str(layout_data.presentation).strip()
):
raise HTTPException(
status_code=400,
detail=f"Layout {i+1}: presentation_id cannot be empty",
)
if not layout_data.layout_id or not layout_data.layout_id.strip():
raise HTTPException(
status_code=400, detail=f"Layout {i+1}: layout_id cannot be empty"
)
if not layout_data.layout_name or not layout_data.layout_name.strip():
raise HTTPException(
status_code=400, detail=f"Layout {i+1}: layout_name cannot be empty"
)
if not layout_data.layout_code or not layout_data.layout_code.strip():
raise HTTPException(
status_code=400, detail=f"Layout {i+1}: layout_code cannot be empty"
)
# Check if layout already exists for this presentation and layout_id
stmt = select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == layout_data.presentation,
PresentationLayoutCodeModel.layout_id == layout_data.layout_id,
)
result = await session.execute(stmt)
existing_layout = result.scalar_one_or_none()
if existing_layout:
# Update existing layout
existing_layout.layout_name = layout_data.layout_name
existing_layout.layout_code = layout_data.layout_code
existing_layout.fonts = layout_data.fonts
existing_layout.updated_at = datetime.now()
else:
# Create new layout
new_layout = PresentationLayoutCodeModel(
presentation=layout_data.presentation,
layout_id=layout_data.layout_id,
layout_name=layout_data.layout_name,
layout_code=layout_data.layout_code,
fonts=layout_data.fonts,
)
session.add(new_layout)
saved_count += 1
await session.commit()
return SaveLayoutsResponse(
success=True,
saved_count=saved_count,
message=f"Successfully saved {saved_count} layout(s)",
)
except HTTPException:
# Re-raise HTTP exceptions as-is
await session.rollback()
raise
except Exception as e:
await session.rollback()
print(f"Unexpected error saving layouts: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Internal server error while saving layouts: {str(e)}",
)
# ENDPOINT 5: Get layouts for a presentation
@LAYOUT_MANAGEMENT_ROUTER.get(
"/get-templates/{presentation}",
response_model=GetLayoutsResponse,
responses={
400: {"model": ErrorResponse, "description": "Invalid presentation ID"},
404: {
"model": ErrorResponse,
"description": "No layouts found for presentation",
},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_layouts(
presentation: UUID, session: AsyncSession = Depends(get_async_session)
):
"""
Retrieve all layouts for a specific presentation.
Args:
presentation: UUID of the presentation
session: Database session
Returns:
GetLayoutsResponse with layouts data
Raises:
HTTPException: 404 if no layouts found, 400 for invalid UUID, 500 for server errors
"""
try:
# Validate presentation_id format (basic UUID check)
if not presentation or len(str(presentation).strip()) == 0:
raise HTTPException(
status_code=400, detail="Presentation ID cannot be empty"
)
# Query layouts for the given presentation_id
stmt = select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == presentation
)
result = await session.execute(stmt)
layouts_db = result.scalars().all()
# Check if any layouts were found
if not layouts_db:
raise HTTPException(
status_code=404,
detail=f"No layouts found for presentation ID: {presentation}",
)
# Convert to response format
layouts = [
LayoutData(
presentation=layout.presentation,
layout_id=layout.layout_id,
layout_name=layout.layout_name,
layout_code=layout.layout_code,
fonts=layout.fonts,
db_id=layout.id,
is_enabled=layout.is_enabled if layout.is_enabled is not None else True,
thumbnail_path=layout.thumbnail_path,
)
for layout in layouts_db
]
# Aggregate unique fonts across all layouts
aggregated_fonts: set[str] = set()
for layout in layouts_db:
if layout.fonts:
aggregated_fonts.update([f for f in layout.fonts if isinstance(f, str)])
fonts_list = sorted(list(aggregated_fonts)) if aggregated_fonts else None
# Fetch template meta
template_meta = await session.get(TemplateModel, presentation)
template = None
if template_meta:
template = {
"id": template_meta.id,
"name": template_meta.name,
"description": template_meta.description,
"created_at": template_meta.created_at,
}
return GetLayoutsResponse(
success=True,
layouts=layouts,
message=f"Retrieved {len(layouts)} layout(s) for presentation {presentation}",
template=template,
fonts=fonts_list,
)
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
print(f"Error retrieving layouts for presentation {presentation}: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Internal server error while retrieving layouts: {str(e)}",
)
# ENDPOINT: Get all presentations with layout counts
@LAYOUT_MANAGEMENT_ROUTER.get(
"/summary",
response_model=GetPresentationSummaryResponse,
summary="Get all presentations with layout counts",
description="Retrieve a summary of all presentations and the number of layouts in each",
responses={
200: {
"model": GetPresentationSummaryResponse,
"description": "Presentations summary retrieved successfully",
},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def get_presentations_summary(
session: AsyncSession = Depends(get_async_session),
):
"""
Get summary of all presentations with their layout counts.
"""
try:
# Query to get presentation_id, count of layouts, and MAX(updated_at)
stmt = select(
PresentationLayoutCodeModel.presentation,
func.count(PresentationLayoutCodeModel.id).label("layout_count"),
func.max(PresentationLayoutCodeModel.updated_at).label("last_updated_at"),
).group_by(PresentationLayoutCodeModel.presentation)
result = await session.execute(stmt)
presentation_data = result.all()
# Convert to response format with template info if available
presentations = []
for row in presentation_data:
template_meta = await session.get(TemplateModel, row.presentation)
template = None
if template_meta:
template = {
"id": template_meta.id,
"name": template_meta.name,
"description": template_meta.description,
"created_at": template_meta.created_at,
}
presentations.append(
PresentationSummary(
presentation_id=row.presentation,
layout_count=row.layout_count,
last_updated_at=row.last_updated_at,
template=template,
)
)
# Calculate totals
total_presentations = len(presentations)
total_layouts = sum(p.layout_count for p in presentations)
return GetPresentationSummaryResponse(
success=True,
presentations=presentations,
total_presentations=total_presentations,
total_layouts=total_layouts,
message=f"Retrieved {total_presentations} presentation(s) with {total_layouts} total layout(s)",
)
except Exception as e:
print(f"Error retrieving presentations summary: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Internal server error while retrieving presentations summary: {str(e)}",
)
@LAYOUT_MANAGEMENT_ROUTER.post(
"/templates",
response_model=TemplateCreateResponse,
responses={
400: {"model": ErrorResponse, "description": "Validation error"},
500: {"model": ErrorResponse, "description": "Internal server error"},
},
)
async def create_template(
request: TemplateCreateRequest,
session: AsyncSession = Depends(get_async_session),
):
try:
if not request.id or not request.name:
raise HTTPException(status_code=400, detail="id and name are required")
# Upsert template by id
existing = await session.get(TemplateModel, request.id)
if existing:
existing.name = request.name
existing.description = request.description
else:
session.add(
TemplateModel(
id=request.id, name=request.name, description=request.description
)
)
await session.commit()
# Read back
template = await session.get(TemplateModel, request.id)
return TemplateCreateResponse(
success=True,
template={
"id": template.id,
"name": template.name,
"description": template.description,
"created_at": template.created_at,
},
message="Template saved",
)
except HTTPException:
await session.rollback()
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to save template: {str(e)}"
)
@LAYOUT_MANAGEMENT_ROUTER.delete("/delete-templates/{template_id}", status_code=204)
async def delete_template(
template_id: UUID,
session: AsyncSession = Depends(get_async_session),
):
try:
await session.execute(
delete(TemplateModel).where(TemplateModel.id == template_id)
)
await session.execute(
delete(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == template_id,
)
)
await session.commit()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete template")
class ToggleLayoutEnabledResponse(BaseModel):
ok: bool
is_enabled: bool
@LAYOUT_MANAGEMENT_ROUTER.patch(
"/layouts/{layout_db_id}/toggle-enabled",
response_model=ToggleLayoutEnabledResponse,
)
async def toggle_layout_enabled(
layout_db_id: int,
session: AsyncSession = Depends(get_async_session),
):
"""Toggle the is_enabled flag for a specific layout."""
layout = await session.get(PresentationLayoutCodeModel, layout_db_id)
if not layout:
raise HTTPException(status_code=404, detail="Layout not found")
layout.is_enabled = not (layout.is_enabled if layout.is_enabled is not None else True)
await session.commit()
return ToggleLayoutEnabledResponse(ok=True, is_enabled=layout.is_enabled)