Files
2026-07-13 13:08:41 +08:00

947 lines
30 KiB
Python

import os
import random
import re
import uuid
from datetime import datetime
from typing import Any, Awaitable, Callable, List, Optional
import aiohttp
from fastapi import Body, Depends, File, Form, HTTPException, Path, Query, UploadFile
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import delete, select
from constants.presentation import DEFAULT_TEMPLATES
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sql.template import TemplateModel
from models.sql.template_create_info import TemplateCreateInfoModel
from services.database import async_session_maker, get_async_session
from services.export_task_service import EXPORT_TASK_SERVICE
from templates.example import build_template_example
from templates.get_layout_by_name import get_layout_by_name
from templates.layout_code_validation import (
LayoutCodeValidationError,
LayoutCodeValidationServiceError,
ValidatedLayoutCode,
validate_layout_code,
)
from templates.presentation_layout import PresentationLayoutModel
from templates.preview import (
FontsUploadAndSlidesPreviewResponse,
upload_fonts_and_slides_preview_handler,
)
from templates.prompts import (
SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,
SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,
SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,
)
from templates.providers import edit_slide_layout_code, generate_slide_layout_code
from templates.slide_layout_jobs import (
SlideLayoutJobStartResponse,
SlideLayoutJobStatusResponse,
start_slide_layout_job,
get_slide_layout_job,
)
from utils.asset_directory_utils import (
resolve_app_path_to_filesystem,
resolve_image_path_to_filesystem,
)
LAYOUT_CODE_REPAIR_ATTEMPTS = 3
class TemplateDetail(BaseModel):
id: str
name: str
total_layouts: Optional[int] = None
class TemplateLayoutData(BaseModel):
template: uuid.UUID
layout_id: str
layout_name: str
layout_code: str
fonts: Optional[Any] = None
class TemplateData(BaseModel):
id: uuid.UUID
init_id: Optional[uuid.UUID] = None
name: str
description: Optional[str] = None
created_at: datetime
class GetTemplateLayoutsResponse(BaseModel):
layouts: list[TemplateLayoutData]
template: Optional[TemplateData] = None
fonts: Optional[Any] = None
class TemplateExample(BaseModel):
template: str
slides: List[dict]
class CreateTemplateInitRequest(BaseModel):
pptx_url: str
slide_image_urls: List[str]
fonts: dict = {}
class CreateSlideLayoutRequest(BaseModel):
id: uuid.UUID
index: int
class CreateSlideLayoutResponse(BaseModel):
react_component: str
class EditSlideLayoutRequest(BaseModel):
react_component: str
prompt: str
class EditSlideLayoutResponse(CreateSlideLayoutResponse):
pass
class EditSlideLayoutSectionRequest(BaseModel):
react_component: str
section: str
prompt: str
class EditSlideLayoutSectionResponse(CreateSlideLayoutResponse):
pass
class SaveTemplateLayoutData(BaseModel):
layout_id: str
layout_name: str
layout_code: str
class SaveTemplateRequest(BaseModel):
template_info_id: uuid.UUID
name: str
description: Optional[str] = None
layouts: List[SaveTemplateLayoutData]
class SaveTemplateResponse(BaseModel):
id: uuid.UUID
name: str
description: Optional[str] = None
created_at: datetime
class CloneTemplateRequest(BaseModel):
id: str
name: str
description: Optional[str] = None
class UpdateTemplateRequest(BaseModel):
id: uuid.UUID
layouts: List[SaveTemplateLayoutData]
class SaveSlideLayoutRequest(BaseModel):
template_id: uuid.UUID
layout_id: str
layout_code: str
class CloneSlideLayoutRequest(BaseModel):
template_id: str
layout_id: str
layout_name: Optional[str] = None
def _strip_code_fences(value: str) -> str:
return (
value.replace("```tsx", "")
.replace("```typescript", "")
.replace("```ts", "")
.replace("```", "")
.strip()
)
_ASSET_FIELD_REPLACEMENTS = {
"image_url": "__image_url__",
"icon_url": "__icon_url__",
"image_prompt": "__image_prompt__",
"icon_query": "__icon_query__",
}
_ASSET_FIELD_DEFAULTS = {
"__image_url__": "/static/images/replaceable_template_image.png",
"__icon_url__": "/static/icons/placeholder.svg",
"__image_prompt__": "replaceable image",
"__icon_query__": "placeholder icon",
}
def _normalize_asset_fields(code: str) -> str:
normalized = code
for field_name, normalized_name in _ASSET_FIELD_REPLACEMENTS.items():
normalized = re.sub(
rf"(?<!_)\b{re.escape(field_name)}\b(?!_)",
normalized_name,
normalized,
)
# Models occasionally emit a bare object shorthand without a comma/value:
# icon: {
# __icon_url__
# __icon_query__: "play"
# }
# These asset fields are not in scope as variables, so make them valid defaults.
def replace_bare_asset_field(match: re.Match[str]) -> str:
indentation, field_name = match.groups()
default_value = _ASSET_FIELD_DEFAULTS[field_name]
return f'{indentation}{field_name}: "{default_value}",'
return re.sub(
r"(?m)^(\s*)(__(?:image_url|icon_url|image_prompt|icon_query)__)\s*,?\s*$",
replace_bare_asset_field,
normalized,
)
def _normalize_layout_code_for_create(code: str) -> str:
normalized = _normalize_asset_fields(_strip_code_fences(code))
first_import_match = re.search(r"(?m)^\s*import\b", normalized)
if first_import_match:
normalized = normalized[first_import_match.start() :]
first_export_match = re.search(r"(?m)^\s*export\b", normalized)
if first_export_match:
normalized = normalized[: first_export_match.start()]
normalized = re.sub(
r"(?ms)^\s*(?:import|export)\b.*?;(?:\r?\n|$)",
"",
normalized,
)
normalized = re.sub(
r"(?m)^\s*(?:import|export)\b.*(?:\r?\n|$)",
"",
normalized,
)
normalized = normalized.strip()
normalized = re.sub(
r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',
lambda match: (
match.group(0)
if re.search(r"-\d{4}$", match.group(2))
else f"{match.group(1)}{match.group(2)}-{random.randint(1000, 9999)}{match.group(3)}"
),
normalized,
)
return normalized
def _update_layout_id_in_code(code: str) -> tuple[str, str]:
match = re.search(r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])', code)
if not match:
raise HTTPException(status_code=400, detail="layoutId not found in layout code")
current_id = match.group(2)
suffix = f"{random.randint(1000, 9999)}"
new_id = re.sub(r"-\d{4}$", f"-{suffix}", current_id)
if new_id == current_id:
new_id = f"{current_id}-{suffix}"
new_code = re.sub(
r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',
f"\\1{new_id}\\3",
code,
count=1,
)
return new_code, new_id
def _validation_error_message(exc: LayoutCodeValidationError) -> str:
location = ""
if exc.line is not None:
location = f" at {exc.line}:{exc.column or 1}"
return f"{exc.error}{location}"
def _validation_repair_user_text(
*,
original_user_text: str,
invalid_code: str,
validation_error: LayoutCodeValidationError,
) -> str:
return (
f"{original_user_text}\n\n"
"#VALIDATION ERROR\n"
"The TSX code you returned failed validation. Return a complete corrected "
"TSX layout code only, with no markdown fences and no explanation.\n"
f"Error: {_validation_error_message(validation_error)}\n\n"
"#INVALID TSX CODE RETURNED\n"
f"{invalid_code}"
)
async def _validate_submitted_layout_code(code: str) -> ValidatedLayoutCode:
try:
return await validate_layout_code(code)
except LayoutCodeValidationError as exc:
raise HTTPException(status_code=400, detail=exc.to_detail()) from exc
except LayoutCodeValidationServiceError as exc:
raise HTTPException(
status_code=502,
detail=f"Layout code validation service failed: {str(exc)}",
) from exc
async def _validate_provider_layout_code_or_retry(
*,
code: str,
retry_call: Callable[[str], Awaitable[str]],
original_user_text: str,
normalize_code: Callable[[str], str],
) -> ValidatedLayoutCode:
normalized_code = normalize_code(code)
try:
return await validate_layout_code(normalized_code)
except LayoutCodeValidationError as validation_error:
invalid_code = normalized_code
last_error = validation_error
for _attempt in range(LAYOUT_CODE_REPAIR_ATTEMPTS):
repair_user_text = _validation_repair_user_text(
original_user_text=original_user_text,
invalid_code=invalid_code,
validation_error=last_error,
)
repaired_code = normalize_code(await retry_call(repair_user_text))
try:
return await validate_layout_code(repaired_code)
except LayoutCodeValidationError as retry_error:
invalid_code = repaired_code
last_error = retry_error
except LayoutCodeValidationServiceError as exc:
raise HTTPException(
status_code=502,
detail=f"Layout code validation service failed: {str(exc)}",
) from exc
raise HTTPException(
status_code=502,
detail=(
"Template provider returned invalid layout code after "
f"{LAYOUT_CODE_REPAIR_ATTEMPTS} repair attempts: "
f"{_validation_error_message(last_error)}"
),
) from last_error
except LayoutCodeValidationServiceError as exc:
raise HTTPException(
status_code=502,
detail=f"Layout code validation service failed: {str(exc)}",
) from exc
async def _download_image_bytes(image_url: str) -> bytes:
async with aiohttp.ClientSession() as session:
async with session.get(image_url) as response:
if response.status != 200:
raise HTTPException(
status_code=400,
detail=f"Failed to download slide image: {image_url}",
)
return await response.read()
async def _read_image_bytes_and_media_type(image_url: str) -> tuple[bytes, str]:
actual_image_path = resolve_image_path_to_filesystem(image_url)
if actual_image_path and os.path.isfile(actual_image_path):
with open(actual_image_path, "rb") as image_file:
image_bytes = image_file.read()
file_extension = os.path.splitext(actual_image_path)[1].lower()
else:
image_bytes = await _download_image_bytes(image_url)
file_extension = os.path.splitext(image_url)[1].lower()
media_type_map = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
return image_bytes, media_type_map.get(file_extension, "image/png")
async def get_all_templates(
include_defaults: bool = Query(
default=True, description="Whether to include default templates"
),
sql_session: AsyncSession = Depends(get_async_session),
):
result = await sql_session.execute(
select(
TemplateModel.id,
TemplateModel.name,
func.count(PresentationLayoutCodeModel.id).label("total_layouts"),
)
.join(
PresentationLayoutCodeModel,
PresentationLayoutCodeModel.presentation == TemplateModel.id,
)
.group_by(TemplateModel.id, TemplateModel.name)
)
rows = result.all()
templates: list[TemplateDetail] = []
if include_defaults:
templates.extend(
TemplateDetail(id=template, name=template) for template in DEFAULT_TEMPLATES
)
templates.extend(
TemplateDetail(
id=f"custom-{template_id}",
name=template_name,
total_layouts=total_layouts,
)
for template_id, template_name, total_layouts in rows
)
return templates
async def get_layouts(
template_id: str = Path(..., description="The id of the template"),
session: AsyncSession = Depends(get_async_session),
):
if not template_id or not template_id.strip():
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
try:
cleaned_template_id = template_id.replace("custom-", "")
template_id_uuid = uuid.UUID(cleaned_template_id)
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
result = await session.execute(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == template_id_uuid
)
)
layouts_db = result.scalars().all()
if not layouts_db:
raise HTTPException(
status_code=404,
detail=f"No layouts found for template ID: {template_id}",
)
template_meta = await session.get(TemplateModel, template_id_uuid)
template = None
if template_meta:
template = TemplateData(
id=template_id_uuid,
init_id=None,
name=template_meta.name,
description=template_meta.description,
created_at=template_meta.created_at,
)
layouts = [
TemplateLayoutData(
template=template_id_uuid,
layout_id=layout.layout_id,
layout_name=layout.layout_name,
layout_code=layout.layout_code,
fonts=layout.fonts,
)
for layout in layouts_db
]
return GetTemplateLayoutsResponse(
layouts=layouts,
template=template,
fonts=layouts[0].fonts if layouts else None,
)
async def get_template_by_id(
id: str = Path(
...,
description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",
),
sql_session: AsyncSession = Depends(get_async_session),
):
if id.startswith("custom-"):
try:
template_id = uuid.UUID(id.replace("custom-", ""))
except Exception as exc:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
) from exc
template = await sql_session.get(TemplateModel, template_id)
if not template:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
return await get_layout_by_name(id)
async def get_template_example(
id: str = Path(
...,
description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",
),
sql_session: AsyncSession = Depends(get_async_session),
):
template = await get_template_by_id(id=id, sql_session=sql_session)
return TemplateExample(**build_template_example(id, template))
async def upload_fonts_and_slides_preview(
pptx_file: UploadFile = File(..., description="PPTX file to preview"),
font_files: Optional[List[UploadFile]] = File(
default=None, description="Font files to upload"
),
original_font_names: Optional[List[str]] = Form(default=None),
google_font_original_names: Optional[List[str]] = Form(default=None),
google_font_replacement_names: Optional[List[str]] = Form(default=None),
google_font_names: Optional[List[str]] = Form(default=None),
google_font_urls: Optional[List[str]] = Form(default=None),
):
return await upload_fonts_and_slides_preview_handler(
pptx_file=pptx_file,
font_files=font_files,
original_font_names=original_font_names,
google_font_original_names=google_font_original_names,
google_font_replacement_names=google_font_replacement_names,
google_font_names=google_font_names,
google_font_urls=google_font_urls,
)
async def upload_fonts_for_pptx_import(
pptx_file: UploadFile = File(..., description="PPTX file to prepare"),
font_files: Optional[List[UploadFile]] = File(
default=None, description="Font files to upload"
),
original_font_names: Optional[List[str]] = Form(default=None),
google_font_original_names: Optional[List[str]] = Form(default=None),
google_font_replacement_names: Optional[List[str]] = Form(default=None),
google_font_names: Optional[List[str]] = Form(default=None),
google_font_urls: Optional[List[str]] = Form(default=None),
):
return await upload_fonts_and_slides_preview_handler(
pptx_file=pptx_file,
font_files=font_files,
original_font_names=original_font_names,
google_font_original_names=google_font_original_names,
google_font_replacement_names=google_font_replacement_names,
google_font_names=google_font_names,
google_font_urls=google_font_urls,
get_slide_images=False,
upload_presentation=True,
)
async def init_create_template(
request: CreateTemplateInitRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.slide_image_urls:
raise HTTPException(
status_code=400, detail="At least one slide image is required"
)
pptx_path = resolve_app_path_to_filesystem(request.pptx_url)
if not pptx_path or not os.path.isfile(pptx_path):
raise HTTPException(status_code=400, detail="PPTX file not found")
pptx_document = await EXPORT_TASK_SERVICE.convert_pptx_to_html(
pptx_path, get_fonts=False
)
if not pptx_document.slides:
raise HTTPException(
status_code=500,
detail="PPTX-to-HTML export returned no slides",
)
if len(pptx_document.slides) < len(request.slide_image_urls):
raise HTTPException(
status_code=400,
detail=(
"PPTX-to-HTML export returned fewer slides than the preview images. "
f"Expected at least {len(request.slide_image_urls)}, got {len(pptx_document.slides)}."
),
)
slide_htmls = pptx_document.slides[: len(request.slide_image_urls)]
template_create_info = TemplateCreateInfoModel(
fonts=request.fonts or {},
pptx_url=request.pptx_url,
slide_image_urls=request.slide_image_urls,
slide_htmls=slide_htmls,
)
sql_session.add(template_create_info)
await sql_session.commit()
await sql_session.refresh(template_create_info)
return template_create_info.id
async def _create_slide_layout_impl(
sql_session: AsyncSession,
request: CreateSlideLayoutRequest,
) -> CreateSlideLayoutResponse:
template_info = await sql_session.get(TemplateCreateInfoModel, request.id)
if not template_info:
raise HTTPException(status_code=400, detail="Template not found")
total_slides = len(template_info.slide_htmls)
if request.index < 0 or request.index >= total_slides:
raise HTTPException(status_code=400, detail="Invalid slide index")
slide_html = template_info.slide_htmls[request.index]
slide_image_url = template_info.slide_image_urls[request.index]
image_bytes, media_type = await _read_image_bytes_and_media_type(slide_image_url)
fonts_text = ""
if template_info.fonts:
font_names = [font.replace(" ", "_") for font in template_info.fonts.keys()]
fonts_text = "#PROVIDED FONTS\n- " + "\n- ".join(font_names)
user_text = f"{fonts_text}\n\n#SLIDE HTML REFERENCE\n{slide_html}"
async def retry_generation(repair_user_text: str) -> str:
return await generate_slide_layout_code(
system_prompt=SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,
user_text=repair_user_text,
image_bytes=image_bytes,
media_type=media_type,
)
react_component = await retry_generation(user_text)
validated = await _validate_provider_layout_code_or_retry(
code=react_component,
retry_call=retry_generation,
original_user_text=user_text,
normalize_code=_normalize_layout_code_for_create,
)
return CreateSlideLayoutResponse(react_component=validated.layout_code)
async def create_slide_layout(
request: CreateSlideLayoutRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
return await _create_slide_layout_impl(sql_session, request)
async def create_slide_layout_job_start(
request: CreateSlideLayoutRequest = Body(...),
):
req = request.model_copy()
async def work() -> str:
async with async_session_maker() as session:
result = await _create_slide_layout_impl(session, req)
return result.react_component
job_id = await start_slide_layout_job(work)
return SlideLayoutJobStartResponse(job_id=job_id)
async def create_slide_layout_job_status(
job_id: uuid.UUID,
):
rec = await get_slide_layout_job(str(job_id))
if rec is None:
raise HTTPException(status_code=404, detail="Job not found")
return SlideLayoutJobStatusResponse(
status=rec.status,
react_component=rec.react_component,
error=rec.error,
)
async def edit_slide_layout(
request: EditSlideLayoutRequest,
):
user_text = f"#Prompt\n{request.prompt}\n\n#TSX code\n{request.react_component}"
async def retry_edit(repair_user_text: str) -> str:
return await edit_slide_layout_code(
system_prompt=SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,
user_text=repair_user_text,
)
react_component = await retry_edit(user_text)
validated = await _validate_provider_layout_code_or_retry(
code=react_component,
retry_call=retry_edit,
original_user_text=user_text,
normalize_code=lambda code: _normalize_asset_fields(_strip_code_fences(code)),
)
return EditSlideLayoutResponse(react_component=validated.layout_code)
async def edit_slide_layout_section(
request: EditSlideLayoutSectionRequest,
):
user_text = (
f"#Prompt\n{request.prompt}\n\n"
f"#Section to make changes around\n{request.section}\n\n"
f"#TSX code\n{request.react_component}"
)
async def retry_edit(repair_user_text: str) -> str:
return await edit_slide_layout_code(
system_prompt=SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,
user_text=repair_user_text,
)
react_component = await retry_edit(user_text)
validated = await _validate_provider_layout_code_or_retry(
code=react_component,
retry_call=retry_edit,
original_user_text=user_text,
normalize_code=lambda code: _normalize_asset_fields(_strip_code_fences(code)),
)
return EditSlideLayoutSectionResponse(react_component=validated.layout_code)
async def save_template(
request: SaveTemplateRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.layouts:
raise HTTPException(status_code=400, detail="Layouts are required")
template_info = await sql_session.get(TemplateCreateInfoModel, request.template_info_id)
if not template_info:
raise HTTPException(status_code=400, detail="Template info not found")
validated_layouts: list[tuple[SaveTemplateLayoutData, ValidatedLayoutCode]] = []
for layout in request.layouts:
validated_layouts.append(
(layout, await _validate_submitted_layout_code(layout.layout_code))
)
template = TemplateModel(
id=uuid.uuid4(),
name=request.name,
description=request.description,
)
sql_session.add(template)
sql_session.add_all(
[
PresentationLayoutCodeModel(
presentation=template.id,
layout_id=validated.layout_id or layout.layout_id,
layout_name=validated.layout_name or layout.layout_name,
layout_code=validated.layout_code,
fonts=template_info.fonts,
)
for layout, validated in validated_layouts
]
)
await sql_session.commit()
await sql_session.refresh(template)
return SaveTemplateResponse(
id=template.id,
name=template.name,
description=template.description,
created_at=template.created_at,
)
async def clone_template(
request: CloneTemplateRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.id or not request.id.strip():
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
try:
template_id_uuid = uuid.UUID(request.id.replace("custom-", ""))
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
template = await sql_session.get(TemplateModel, template_id_uuid)
if not template:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
result = await sql_session.execute(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == template_id_uuid
)
)
layouts_db = result.scalars().all()
if not layouts_db:
raise HTTPException(status_code=400, detail="No layouts found for template")
new_template = TemplateModel(
id=uuid.uuid4(),
name=request.name,
description=template.description
if request.description is None
else request.description,
)
sql_session.add(new_template)
sql_session.add_all(
[
PresentationLayoutCodeModel(
presentation=new_template.id,
layout_id=layout.layout_id,
layout_name=layout.layout_name,
layout_code=layout.layout_code,
fonts=layout.fonts,
)
for layout in layouts_db
]
)
await sql_session.commit()
await sql_session.refresh(new_template)
return SaveTemplateResponse(
id=new_template.id,
name=new_template.name,
description=new_template.description,
created_at=new_template.created_at,
)
async def update_template(
request: UpdateTemplateRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.layouts:
raise HTTPException(status_code=400, detail="Layouts are required")
template = await sql_session.get(TemplateModel, request.id)
if not template:
raise HTTPException(status_code=400, detail="Template not found")
validated_layouts: list[tuple[SaveTemplateLayoutData, ValidatedLayoutCode]] = []
for layout in request.layouts:
validated_layouts.append(
(layout, await _validate_submitted_layout_code(layout.layout_code))
)
existing_layout = await sql_session.scalar(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == request.id
)
)
fonts = existing_layout.fonts if existing_layout else None
await sql_session.execute(
delete(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == request.id
)
)
sql_session.add_all(
[
PresentationLayoutCodeModel(
presentation=template.id,
layout_id=validated.layout_id or layout.layout_id,
layout_name=validated.layout_name or layout.layout_name,
layout_code=validated.layout_code,
fonts=fonts,
)
for layout, validated in validated_layouts
]
)
await sql_session.commit()
return SaveTemplateResponse(
id=template.id,
name=template.name,
description=template.description,
created_at=template.created_at,
)
async def save_slide_layout(
request: SaveSlideLayoutRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
template = await sql_session.get(TemplateModel, request.template_id)
if not template:
raise HTTPException(status_code=400, detail="Template not found")
layout = await sql_session.scalar(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == request.template_id,
PresentationLayoutCodeModel.layout_id == request.layout_id,
)
)
if not layout:
raise HTTPException(status_code=400, detail="Layout not found")
validated = await _validate_submitted_layout_code(request.layout_code)
layout.layout_code = validated.layout_code
layout.layout_id = validated.layout_id or layout.layout_id
layout.layout_name = validated.layout_name or layout.layout_name
sql_session.add(layout)
await sql_session.commit()
async def clone_slide_layout(
request: CloneSlideLayoutRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.template_id or not request.template_id.strip():
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
try:
template_id_uuid = uuid.UUID(request.template_id.replace("custom-", ""))
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
template = await sql_session.get(TemplateModel, template_id_uuid)
if not template:
raise HTTPException(status_code=400, detail="Template not found")
layout = await sql_session.scalar(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == template_id_uuid,
PresentationLayoutCodeModel.layout_id == request.layout_id,
)
)
if not layout:
raise HTTPException(status_code=400, detail="Layout not found")
new_layout_code, new_layout_id = _update_layout_id_in_code(layout.layout_code)
new_layout = PresentationLayoutCodeModel(
presentation=template_id_uuid,
layout_id=new_layout_id,
layout_name=request.layout_name or layout.layout_name,
layout_code=new_layout_code,
fonts=layout.fonts,
)
sql_session.add(new_layout)
await sql_session.commit()
await sql_session.refresh(new_layout)
return SaveTemplateLayoutData(
layout_id=new_layout.layout_id,
layout_name=new_layout.layout_name,
layout_code=new_layout.layout_code,
)