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

1360 lines
44 KiB
Python

import asyncio
import logging
import os
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from functools import partial
from typing import Any, Optional
from urllib.parse import unquote, urlparse
from fastapi import (
APIRouter,
BackgroundTasks,
Body,
Depends,
File,
Form,
HTTPException,
Path,
Query,
Response,
UploadFile,
)
from pydantic import (
AliasChoices,
BaseModel,
ConfigDict,
Field,
ValidationError,
model_validator,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from models.api_error_model import APIErrorModel
from models.sql.async_task import AsyncTaskModel
from models.sql.template_v2 import TemplateV2
from services.database import async_session_maker, get_async_session
from services.export_task_service import EXPORT_TASK_SERVICE
from templates.preview import (
FontsUploadAndSlidesPreviewResponse,
upload_fonts_and_slides_preview_handler,
)
from templates.v2.generation import (
MAX_PARALLEL_SLIDE_LAYOUTS,
generate_slide_layout,
generate_template,
merge_similar_components,
)
from templates.v2.models.layouts import (
MergedComponents,
RawSlideLayouts,
SlideLayout,
SlideLayouts,
)
from utils.asset_directory_utils import resolve_app_path_to_filesystem
from utils.file_utils import get_original_file_name
TEMPLATES_ROUTER = APIRouter(prefix="/templates", tags=["Templates"])
TEMPLATE_ASSETS_ROUTER = APIRouter(prefix="/template", tags=["Template Assets"])
LOGGER = logging.getLogger(__name__)
_TEMPLATE_LAYOUT_PATCH_LOCKS: dict[str, asyncio.Lock] = {}
_TEMPLATE_LAYOUT_PATCH_LOCKS_GUARD = asyncio.Lock()
ASYNC_TASK_TYPE_TEMPLATE_CREATE = "template.create"
SLIDE_LAYOUT_GENERATION_MAX_TOKENS = 16000
class InitTemplateV2Request(BaseModel):
pptx_url: str
slide_image_urls: list[str]
fonts: dict[str, Any] = Field(default_factory=dict)
name: Optional[str] = None
description: Optional[str] = None
class CreateTemplateV2Request(InitTemplateV2Request):
pass
class GenerateTemplateV2BlocksRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
template_id: str = Field(validation_alias=AliasChoices("template_id", "id"))
class CreateTemplateV2LayoutsRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
template_id: str = Field(validation_alias=AliasChoices("template_id", "id"))
index: Optional[int] = Field(default=None, ge=0)
indices: Optional[list[int]] = None
@model_validator(mode="after")
def _validate_indices(self) -> "CreateTemplateV2LayoutsRequest":
if self.index is None and self.indices is None:
raise ValueError("Either index or indices is required")
if self.index is not None and self.indices is not None:
raise ValueError("Use either index or indices, not both")
values = self.layout_indices
if not values:
raise ValueError("At least one slide index is required")
if len(values) > MAX_PARALLEL_SLIDE_LAYOUTS:
raise ValueError(
f"At most {MAX_PARALLEL_SLIDE_LAYOUTS} slide layouts can be "
"created at once"
)
if any(index < 0 for index in values):
raise ValueError("Slide indices must be non-negative")
if len(values) != len(set(values)):
raise ValueError("Slide indices must be unique")
return self
@property
def layout_indices(self) -> list[int]:
if self.indices is not None:
return list(self.indices)
if self.index is not None:
return [self.index]
return []
class CreatedTemplateV2SlideLayout(BaseModel):
index: int = Field(ge=0)
layout: SlideLayout
class CreateTemplateV2LayoutsResponse(BaseModel):
layouts: list[CreatedTemplateV2SlideLayout]
class PatchTemplateV2SlideLayoutItem(BaseModel):
index: int = Field(ge=0)
layout: SlideLayout
class PatchTemplateV2SlideLayoutRequest(BaseModel):
index: Optional[int] = Field(default=None, ge=0)
layout: Optional[SlideLayout] = None
layouts: Optional[list[PatchTemplateV2SlideLayoutItem]] = None
@model_validator(mode="after")
def _validate_layout_items(self) -> "PatchTemplateV2SlideLayoutRequest":
has_single = self.index is not None or self.layout is not None
has_batch = self.layouts is not None
if has_single and has_batch:
raise ValueError("Use either a single layout or layouts, not both")
if has_single and (self.index is None or self.layout is None):
raise ValueError("Both index and layout are required")
if not has_single and not has_batch:
raise ValueError("Either a single layout or layouts is required")
if has_batch:
if not self.layouts:
raise ValueError("At least one layout is required")
indices = [item.index for item in self.layouts]
if len(indices) != len(set(indices)):
raise ValueError("Layout indices must be unique")
return self
@property
def layout_items(self) -> list[PatchTemplateV2SlideLayoutItem]:
if self.layouts is not None:
return list(self.layouts)
if self.index is None or self.layout is None:
return []
return [
PatchTemplateV2SlideLayoutItem(
index=self.index,
layout=self.layout,
)
]
class UpdateTemplateV2MetadataRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class TemplateV2ListItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
description: Optional[str] = None
layout_count: int = 0
thumbnail: Optional[str] = None
is_default: bool = False
created_at: datetime
updated_at: datetime
class TemplateV2ListResponse(BaseModel):
items: list[TemplateV2ListItem]
total: int
page: int
page_size: int
class TemplateV2Response(TemplateV2ListItem):
raw_layouts: Optional[dict[str, Any]] = None
components: Optional[dict[str, Any]] = None
merged_components: Optional[dict[str, Any]] = None
layouts: Optional[dict[str, Any]] = None
assets: Optional[dict[str, Any]] = None
def _template_v2_task_progress_data(
created_layouts: int,
remaining_layouts: int,
name: str | None = None,
thumbnail: str | None = None,
completed_layout_indices: set[int] | None = None,
) -> dict[str, Any]:
total_layouts = max(created_layouts, 0) + max(remaining_layouts, 0)
if completed_layout_indices is None:
completed_layout_indices = set(range(max(created_layouts, 0)))
return {
"created_layouts": max(created_layouts, 0),
"remaining_layouts": max(remaining_layouts, 0),
"slide_layout_statuses": [
{
"index": index,
"status": "completed"
if index in completed_layout_indices
else "pending",
}
for index in range(total_layouts)
],
"name": name,
"thumbnail": thumbnail,
}
def _template_v2_request_name(request: InitTemplateV2Request) -> str:
return (request.name or "").strip() or _derive_template_name(request.pptx_url, "")
def _template_v2_request_thumbnail(request: InitTemplateV2Request) -> str | None:
for slide_image_url in request.slide_image_urls:
if isinstance(slide_image_url, str) and slide_image_url.strip():
return slide_image_url.strip()
return None
def _derive_template_name(pptx_url: str, pptx_path: str) -> str:
source = pptx_path or unquote(urlparse(pptx_url).path) or pptx_url
basename = os.path.basename(source.rstrip("/"))
if "----" in basename:
basename = get_original_file_name(basename)
name = os.path.splitext(basename)[0].strip()
return name or "Untitled template"
def _collect_image_urls_from_layouts(layouts_json: dict[str, Any]) -> list[str]:
images: list[str] = []
seen: set[str] = set()
def visit(value: Any) -> None:
if isinstance(value, dict):
if value.get("type") == "image":
image_data = value.get("data")
if isinstance(image_data, str):
image_url = image_data.strip()
if image_url and image_url not in seen:
seen.add(image_url)
images.append(image_url)
for child_value in value.values():
visit(child_value)
return
if isinstance(value, list):
for item in value:
visit(item)
visit(layouts_json)
return images
def _count_layouts(layouts_json: Any) -> int:
if isinstance(layouts_json, dict):
layouts = layouts_json.get("layouts")
return len(layouts) if isinstance(layouts, list) else 0
if isinstance(layouts_json, list):
return len(layouts_json)
return 0
async def _generate_slide_layouts(
raw_layouts: RawSlideLayouts,
slide_image_urls: list[str],
fonts: dict[str, str] | None = None,
) -> SlideLayouts:
LOGGER.info(
"[templates.v2.create] slide layout generation start slides=%d",
len(raw_layouts.layouts),
)
try:
generated_layouts = await _run_template_generation_thread(
generate_template,
raw_layouts,
slide_image_urls,
fonts,
)
layouts = _coerce_generated_slide_layouts(generated_layouts)
except (ValidationError, ValueError) as exc:
LOGGER.exception(
"[templates.v2.create] slide layout generation produced invalid output "
"slides=%d",
len(raw_layouts.layouts),
)
raise HTTPException(
status_code=500,
detail="Slide layout generation produced invalid output",
) from exc
LOGGER.info(
"[templates.v2.create] slide layout generation complete slides=%d "
"components=%d",
len(layouts.layouts),
sum(len(layout.components) for layout in layouts.layouts),
)
return layouts
async def _merge_generated_components(layouts: SlideLayouts) -> MergedComponents:
LOGGER.info(
"[templates.v2.create] component de-duplication start components=%d",
sum(len(layout.components) for layout in layouts.layouts),
)
try:
merged_components = await _run_template_generation_thread(
merge_similar_components,
layouts,
)
except (ValidationError, ValueError) as exc:
LOGGER.exception(
"[templates.v2.create] component de-duplication produced invalid output"
)
return MergedComponents(components=[])
LOGGER.info(
"[templates.v2.create] component de-duplication complete merged_components=%d",
len(merged_components.components),
)
return merged_components
async def _commit_template_v2_task_progress(
task: AsyncTaskModel,
sql_session: AsyncSession,
*,
completed_layout_indices: set[int],
total_layouts: int,
name: str | None,
thumbnail: str | None,
) -> None:
task.data = _template_v2_task_progress_data(
created_layouts=len(completed_layout_indices),
remaining_layouts=total_layouts - len(completed_layout_indices),
completed_layout_indices=completed_layout_indices,
name=name,
thumbnail=thumbnail,
)
task.updated_at = datetime.now()
sql_session.add(task)
await sql_session.commit()
def _ensure_unique_async_slide_layout_ids(
layouts: list[SlideLayout],
) -> list[SlideLayout]:
used_ids: set[str] = set()
unique_layouts: list[SlideLayout] = []
for index, layout in enumerate(layouts):
if layout.id not in used_ids:
used_ids.add(layout.id)
unique_layouts.append(layout)
continue
suffix = index + 1
candidate_id = f"{layout.id}_{suffix}"
while candidate_id in used_ids:
suffix += 1
candidate_id = f"{layout.id}_{suffix}"
used_ids.add(candidate_id)
unique_layouts.append(
layout.model_copy(deep=True, update={"id": candidate_id})
)
return unique_layouts
async def _generate_slide_layouts_with_task_progress(
raw_layouts: RawSlideLayouts,
slide_image_urls: list[str],
fonts: dict[str, str] | None,
task: AsyncTaskModel,
sql_session: AsyncSession,
*,
name: str | None,
thumbnail: str | None,
) -> SlideLayouts:
if not raw_layouts.layouts:
raise ValueError("layouts must contain at least one slide layout")
if len(slide_image_urls) != len(raw_layouts.layouts):
raise ValueError("slide_image_urls must contain one image for each layout")
slide_count = len(raw_layouts.layouts)
max_workers = min(MAX_PARALLEL_SLIDE_LAYOUTS, slide_count)
LOGGER.info(
"[templates.v2.create.async] slide layout generation start "
"task_id=%s slides=%d max_parallel=%d",
task.id,
slide_count,
max_workers,
)
loop = asyncio.get_running_loop()
completed_layout_indices: set[int] = set()
layouts_by_index: dict[int, SlideLayout] = {}
async def generate_one(index: int, executor: ThreadPoolExecutor):
generated_layout = await loop.run_in_executor(
executor,
partial(
generate_slide_layout,
raw_layouts.layouts[index],
index,
slide_image_urls[index],
fonts,
max_tokens=SLIDE_LAYOUT_GENERATION_MAX_TOKENS,
),
)
layout = (
generated_layout
if isinstance(generated_layout, SlideLayout)
else SlideLayout.model_validate(generated_layout)
)
return index, layout
with ThreadPoolExecutor(
max_workers=max_workers,
thread_name_prefix="template-v2-slide-layout",
) as executor:
pending_tasks = [
asyncio.create_task(generate_one(index, executor))
for index in range(slide_count)
]
try:
for completed_task in asyncio.as_completed(pending_tasks):
index, layout = await completed_task
layouts_by_index[index] = layout
completed_layout_indices.add(index)
await _commit_template_v2_task_progress(
task,
sql_session,
completed_layout_indices=completed_layout_indices,
total_layouts=slide_count,
name=name,
thumbnail=thumbnail,
)
LOGGER.info(
"[templates.v2.create.async] slide layout complete "
"task_id=%s slide=%d/%d components=%d completed=%d/%d",
task.id,
index + 1,
slide_count,
len(layout.components),
len(completed_layout_indices),
slide_count,
)
except Exception:
for pending_task in pending_tasks:
pending_task.cancel()
raise
ordered_layouts = [layouts_by_index[index] for index in range(slide_count)]
unique_layouts = _ensure_unique_async_slide_layout_ids(ordered_layouts)
layouts = _with_randomized_layout_ids(SlideLayouts(layouts=unique_layouts))
LOGGER.info(
"[templates.v2.create.async] slide layout generation complete "
"task_id=%s slides=%d components=%d",
task.id,
len(layouts.layouts),
sum(len(layout.components) for layout in layouts.layouts),
)
return layouts
async def _run_template_generation_thread(func: Any, *args: Any) -> Any:
loop = asyncio.get_running_loop()
with ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="template-v2-generation",
) as executor:
return await loop.run_in_executor(executor, partial(func, *args))
def _coerce_generated_slide_layouts(generated_layouts: Any) -> SlideLayouts:
if isinstance(generated_layouts, SlideLayouts):
return generated_layouts
return SlideLayouts.model_validate(generated_layouts)
def _coerce_template_slide_layouts(layouts_json: Any) -> SlideLayouts:
if isinstance(layouts_json, SlideLayouts):
return layouts_json
if isinstance(layouts_json, list):
return SlideLayouts.model_validate({"layouts": layouts_json})
return SlideLayouts.model_validate(layouts_json)
def _with_randomized_layout_ids(layouts: SlideLayouts) -> SlideLayouts:
return SlideLayouts(
layouts=[
layout.model_copy(
deep=True,
update={
"id": f"{layout.id}_{random.randint(1000, 9999)}",
},
)
for layout in layouts.layouts
]
)
def _get_template_slide_image_urls(template: TemplateV2) -> list[str | None]:
if not isinstance(template.assets, dict):
return []
slide_image_urls = template.assets.get("slide_image_urls")
if not isinstance(slide_image_urls, list):
return []
return [
slide_image_url.strip()
if isinstance(slide_image_url, str) and slide_image_url.strip()
else None
for slide_image_url in slide_image_urls
]
def _coerce_font_map(value: Any) -> dict[str, str]:
if not isinstance(value, dict):
return {}
return {
name.strip(): url.strip()
for name, url in value.items()
if isinstance(name, str)
and isinstance(url, str)
and name.strip()
and url.strip()
}
def _get_template_fonts(template: TemplateV2) -> dict[str, str]:
if not isinstance(template.assets, dict):
return {}
return _coerce_font_map(template.assets.get("fonts"))
def _get_template_thumbnail_from_assets(assets: Any) -> str | None:
if not isinstance(assets, dict):
return None
thumbnail = assets.get("thumbnail")
if isinstance(thumbnail, str) and thumbnail.strip():
return thumbnail.strip()
slide_image_urls = assets.get("slide_image_urls")
if not isinstance(slide_image_urls, list):
return None
for slide_image_url in slide_image_urls:
if isinstance(slide_image_url, str) and slide_image_url.strip():
return slide_image_url.strip()
return None
async def _get_template_layout_patch_lock(template_id: str) -> asyncio.Lock:
async with _TEMPLATE_LAYOUT_PATCH_LOCKS_GUARD:
lock = _TEMPLATE_LAYOUT_PATCH_LOCKS.get(template_id)
if lock is None:
lock = asyncio.Lock()
_TEMPLATE_LAYOUT_PATCH_LOCKS[template_id] = lock
return lock
async def _prepare_template_v2_source(
request: InitTemplateV2Request,
*,
operation: str,
) -> tuple[str, RawSlideLayouts, dict[str, Any], dict[str, str]]:
LOGGER.info(
"[templates.v2.%s] request received pptx_url=%s slide_images=%d "
"font_count=%d has_name=%s",
operation,
request.pptx_url,
len(request.slide_image_urls),
len(request.fonts or {}),
bool((request.name or "").strip()),
)
if not request.slide_image_urls:
LOGGER.warning(
"[templates.v2.%s] rejected request without slide images pptx_url=%s",
operation,
request.pptx_url,
)
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):
LOGGER.warning(
"[templates.v2.%s] rejected request; PPTX file not found "
"pptx_url=%s resolved_path=%s",
operation,
request.pptx_url,
pptx_path,
)
raise HTTPException(status_code=400, detail="PPTX file not found")
LOGGER.info(
"[templates.v2.%s] converting PPTX to JSON pptx_path=%s",
operation,
pptx_path,
)
pptx_json = await EXPORT_TASK_SERVICE.convert_pptx_to_json(pptx_path)
try:
raw_layouts = RawSlideLayouts.model_validate(
pptx_json.model_dump(mode="json")
)
except ValidationError as exc:
LOGGER.exception(
"[templates.v2.%s] PPTX-to-JSON export produced invalid slide "
"layout JSON pptx_path=%s",
operation,
pptx_path,
)
raise HTTPException(
status_code=500,
detail="PPTX-to-JSON export produced invalid slide layout JSON",
) from exc
LOGGER.info(
"[templates.v2.%s] PPTX-to-JSON validation complete pptx_path=%s "
"slides=%d",
operation,
pptx_path,
len(raw_layouts.layouts),
)
if len(raw_layouts.layouts) > len(request.slide_image_urls):
LOGGER.info(
"[templates.v2.%s] capping raw layouts to preview images "
"raw_slides=%d slide_images=%d",
operation,
len(raw_layouts.layouts),
len(request.slide_image_urls),
)
raw_layouts = RawSlideLayouts(
layouts=raw_layouts.layouts[: len(request.slide_image_urls)]
)
elif len(request.slide_image_urls) > len(raw_layouts.layouts):
raise HTTPException(
status_code=400,
detail="Exactly one slide image is required for each slide layout",
)
return (
pptx_path,
raw_layouts,
raw_layouts.model_dump(mode="json", exclude_none=True),
_coerce_font_map(request.fonts),
)
def _layout_indexes_from_assets(assets: Any, layout_count: int) -> list[int]:
if isinstance(assets, dict):
indexes = assets.get("layout_indexes")
if (
isinstance(indexes, list)
and len(indexes) == layout_count
and all(isinstance(index, int) and index >= 0 for index in indexes)
and len(indexes) == len(set(indexes))
):
return list(indexes)
return list(range(layout_count))
def _raw_layout_count(template: TemplateV2) -> int | None:
if not isinstance(template.raw_layouts, dict):
return None
layouts = template.raw_layouts.get("layouts")
return len(layouts) if isinstance(layouts, list) else None
def _merge_template_layout_items(
template: TemplateV2,
items: list[PatchTemplateV2SlideLayoutItem],
) -> tuple[SlideLayouts, list[int]]:
existing_layouts = (
_coerce_template_slide_layouts(template.layouts)
if template.layouts is not None
else None
)
existing_items = existing_layouts.layouts if existing_layouts else []
layout_indexes = _layout_indexes_from_assets(template.assets, len(existing_items))
layout_by_index = dict(zip(layout_indexes, existing_items))
raw_slide_count = _raw_layout_count(template)
if raw_slide_count is None:
max_slide_count = len(existing_layouts.layouts) if existing_layouts else None
elif existing_layouts is not None:
max_slide_count = max(raw_slide_count, len(existing_layouts.layouts))
else:
max_slide_count = raw_slide_count
for item in items:
if max_slide_count is not None and item.index >= max_slide_count:
raise HTTPException(status_code=400, detail="Invalid slide index")
layout_by_index[item.index] = item.layout
ordered_indexes = sorted(layout_by_index)
try:
return (
SlideLayouts(
layouts=[layout_by_index[index] for index in ordered_indexes]
),
ordered_indexes,
)
except ValidationError as exc:
raise HTTPException(
status_code=400,
detail="Patched template layouts are invalid",
) from exc
def _generate_indexed_slide_layouts(
raw_layouts: RawSlideLayouts,
indices: list[int],
slide_image_urls: list[str | None],
fonts: dict[str, str],
) -> list[CreatedTemplateV2SlideLayout]:
max_workers = min(MAX_PARALLEL_SLIDE_LAYOUTS, len(indices))
layouts_by_index: dict[int, SlideLayout] = {}
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
generate_slide_layout,
raw_layouts.layouts[index],
index,
slide_image_urls[index],
fonts,
max_tokens=SLIDE_LAYOUT_GENERATION_MAX_TOKENS,
): index
for index in indices
}
for future in as_completed(futures):
index = futures[future]
generated_layout = future.result()
layouts_by_index[index] = (
generated_layout
if isinstance(generated_layout, SlideLayout)
else SlideLayout.model_validate(generated_layout)
)
ordered_layouts = [layouts_by_index[index] for index in indices]
randomized = _with_randomized_layout_ids(SlideLayouts(layouts=ordered_layouts))
return [
CreatedTemplateV2SlideLayout(index=index, layout=layout)
for index, layout in zip(indices, randomized.layouts)
]
@TEMPLATES_ROUTER.get("", response_model=TemplateV2ListResponse)
async def list_templates_v2(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
sql_session: AsyncSession = Depends(get_async_session),
):
offset = (page - 1) * page_size
result = await sql_session.execute(
select(
TemplateV2.id,
TemplateV2.name,
TemplateV2.description,
TemplateV2.layouts,
TemplateV2.assets,
TemplateV2.is_default,
TemplateV2.created_at,
TemplateV2.updated_at,
)
.order_by(TemplateV2.created_at.desc())
)
items: list[TemplateV2ListItem] = []
for (
template_id,
name,
description,
layouts,
assets,
is_default,
created_at,
updated_at,
) in result.all():
layout_count = _count_layouts(layouts)
if layout_count == 0:
continue
items.append(
TemplateV2ListItem(
id=template_id,
name=name,
description=description,
layout_count=layout_count,
thumbnail=_get_template_thumbnail_from_assets(assets),
is_default=is_default,
created_at=created_at,
updated_at=updated_at,
)
)
return TemplateV2ListResponse(
items=items[offset : offset + page_size],
total=len(items),
page=page,
page_size=page_size,
)
@TEMPLATE_ASSETS_ROUTER.post(
"/fonts-upload-and-slides-preview",
response_model=FontsUploadAndSlidesPreviewResponse,
)
async def upload_template_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,
)
@TEMPLATES_ROUTER.post(
"/init",
status_code=201,
response_model=str,
)
async def init_template_v2(
request: InitTemplateV2Request = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
pptx_path, raw_layouts, raw_layouts_json, available_fonts = (
await _prepare_template_v2_source(request, operation="init")
)
template = TemplateV2(
name=(request.name or "").strip() or _derive_template_name(
request.pptx_url, pptx_path
),
description=request.description,
raw_layouts=raw_layouts_json,
layouts=None,
assets={
"pptx_url": request.pptx_url,
"fonts": available_fonts,
"slide_image_urls": request.slide_image_urls,
"images": _collect_image_urls_from_layouts(raw_layouts_json),
"layout_indexes": [],
},
)
LOGGER.info(
"[templates.v2.init] persisting template name=%s slides=%d images=%d",
template.name,
len(raw_layouts.layouts),
len(template.assets.get("images", [])),
)
sql_session.add(template)
await sql_session.commit()
await sql_session.refresh(template)
LOGGER.info(
"[templates.v2.init] template persisted template_id=%s name=%s",
template.id,
template.name,
)
return template.id
def _build_created_template_v2(
request: CreateTemplateV2Request,
*,
pptx_path: str,
raw_layouts_json: dict[str, Any],
available_fonts: dict[str, str],
generated_layouts: SlideLayouts,
merged_components: MergedComponents,
) -> TemplateV2:
return TemplateV2(
name=(request.name or "").strip() or _derive_template_name(
request.pptx_url, pptx_path
),
description=request.description,
raw_layouts=raw_layouts_json,
merged_components=merged_components.model_dump(
mode="json", exclude_none=True
),
layouts=generated_layouts.model_dump(mode="json", exclude_none=True),
assets={
"fonts": available_fonts,
"slide_image_urls": request.slide_image_urls,
"images": _collect_image_urls_from_layouts(raw_layouts_json),
},
)
async def _create_template_v2_sync(
request: CreateTemplateV2Request = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
pptx_path, raw_layouts, raw_layouts_json, available_fonts = (
await _prepare_template_v2_source(request, operation="create")
)
generated_layouts = await _generate_slide_layouts(
raw_layouts,
request.slide_image_urls,
available_fonts,
)
generated_layouts = _with_randomized_layout_ids(generated_layouts)
merged_components = await _merge_generated_components(generated_layouts)
template = _build_created_template_v2(
request,
pptx_path=pptx_path,
raw_layouts_json=raw_layouts_json,
available_fonts=available_fonts,
generated_layouts=generated_layouts,
merged_components=merged_components,
)
LOGGER.info(
"[templates.v2.create] persisting template name=%s slides=%d images=%d",
template.name,
len(raw_layouts.layouts),
len(template.assets.get("images", [])),
)
sql_session.add(template)
await sql_session.commit()
await sql_session.refresh(template)
LOGGER.info(
"[templates.v2.create] template persisted template_id=%s name=%s",
template.id,
template.name,
)
return template
async def _create_template_v2_with_task_progress(
request: CreateTemplateV2Request,
task: AsyncTaskModel,
sql_session: AsyncSession,
) -> TemplateV2:
pptx_path, raw_layouts, raw_layouts_json, available_fonts = (
await _prepare_template_v2_source(request, operation="create")
)
name = (request.name or "").strip() or _derive_template_name(
request.pptx_url, pptx_path
)
thumbnail = _template_v2_request_thumbnail(request)
await _commit_template_v2_task_progress(
task,
sql_session,
completed_layout_indices=set(),
total_layouts=len(raw_layouts.layouts),
name=name,
thumbnail=thumbnail,
)
try:
generated_layouts = await _generate_slide_layouts_with_task_progress(
raw_layouts,
request.slide_image_urls,
available_fonts,
task,
sql_session,
name=name,
thumbnail=thumbnail,
)
except (ValidationError, ValueError) as exc:
LOGGER.exception(
"[templates.v2.create.async] slide layout generation produced "
"invalid output task_id=%s slides=%d",
task.id,
len(raw_layouts.layouts),
)
raise HTTPException(
status_code=500,
detail="Slide layout generation produced invalid output",
) from exc
merged_components = await _merge_generated_components(generated_layouts)
template = _build_created_template_v2(
request,
pptx_path=pptx_path,
raw_layouts_json=raw_layouts_json,
available_fonts=available_fonts,
generated_layouts=generated_layouts,
merged_components=merged_components,
)
LOGGER.info(
"[templates.v2.create.async] persisting template task_id=%s name=%s "
"slides=%d images=%d",
task.id,
template.name,
len(raw_layouts.layouts),
len(template.assets.get("images", [])),
)
sql_session.add(template)
await sql_session.commit()
await sql_session.refresh(template)
LOGGER.info(
"[templates.v2.create.async] template persisted task_id=%s "
"template_id=%s name=%s",
task.id,
template.id,
template.name,
)
return template
async def _run_create_template_v2_task(
task_id: str,
request: CreateTemplateV2Request,
) -> None:
async with async_session_maker() as sql_session:
task = await sql_session.get(AsyncTaskModel, task_id)
if not task:
LOGGER.warning(
"[templates.v2.create.async] task missing task_id=%s",
task_id,
)
return
try:
task.status = "processing"
task.message = "Creating template"
task.data = _template_v2_task_progress_data(
created_layouts=0,
remaining_layouts=len(request.slide_image_urls),
name=_template_v2_request_name(request),
thumbnail=_template_v2_request_thumbnail(request),
)
task.updated_at = datetime.now()
sql_session.add(task)
await sql_session.commit()
task.message = "Generating slide layouts"
template = await _create_template_v2_with_task_progress(
request,
task,
sql_session,
)
created_layouts = _count_layouts(template.layouts)
task.status = "completed"
task.message = "Template creation completed"
task.data = _template_v2_task_progress_data(
created_layouts=created_layouts,
remaining_layouts=len(request.slide_image_urls) - created_layouts,
name=template.name,
thumbnail=_get_template_thumbnail_from_assets(template.assets),
)
task.updated_at = datetime.now()
sql_session.add(task)
await sql_session.commit()
except Exception as exc:
LOGGER.exception(
"[templates.v2.create.async] template creation failed task_id=%s",
task_id,
)
task.status = "error"
task.message = "Template creation failed"
api_error = APIErrorModel.from_exception(
exc
if isinstance(exc, HTTPException)
else HTTPException(status_code=500, detail="Template creation failed")
)
task.error = api_error.model_dump(mode="json")
task.updated_at = datetime.now()
sql_session.add(task)
await sql_session.commit()
@TEMPLATES_ROUTER.post(
"/async",
status_code=201,
response_model=AsyncTaskModel,
)
async def create_template_v2(
background_tasks: BackgroundTasks,
request: CreateTemplateV2Request = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
task = AsyncTaskModel(
type=ASYNC_TASK_TYPE_TEMPLATE_CREATE,
status="pending",
message="Queued for template creation",
data=_template_v2_task_progress_data(
created_layouts=0,
remaining_layouts=len(request.slide_image_urls),
name=_template_v2_request_name(request),
thumbnail=_template_v2_request_thumbnail(request),
),
)
sql_session.add(task)
await sql_session.commit()
await sql_session.refresh(task)
background_tasks.add_task(_run_create_template_v2_task, task.id, request)
return task
@TEMPLATES_ROUTER.post(
"/layouts/create",
response_model=CreateTemplateV2LayoutsResponse,
)
async def create_template_v2_slide_layouts(
request: CreateTemplateV2LayoutsRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
template = await sql_session.get(TemplateV2, request.template_id)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
if not isinstance(template.raw_layouts, dict):
raise HTTPException(
status_code=400,
detail="Template raw layouts are unavailable",
)
try:
raw_layouts = RawSlideLayouts.model_validate(template.raw_layouts)
except ValidationError as exc:
LOGGER.exception(
"[templates.v2.layouts.create] template has invalid raw layouts "
"template_id=%s",
request.template_id,
)
raise HTTPException(
status_code=500,
detail="Template raw layouts are invalid",
) from exc
indices = request.layout_indices
if any(index >= len(raw_layouts.layouts) for index in indices):
raise HTTPException(status_code=400, detail="Invalid slide index")
slide_image_urls = _get_template_slide_image_urls(template)
missing_slide_image = any(
index >= len(slide_image_urls) or slide_image_urls[index] is None
for index in indices
)
if missing_slide_image:
raise HTTPException(
status_code=400,
detail="Slide image URL is unavailable for requested slide index",
)
LOGGER.info(
"[templates.v2.layouts.create] slide layout creation start "
"template_id=%s slides=%s/%d",
request.template_id,
",".join(str(index + 1) for index in indices),
len(raw_layouts.layouts),
)
try:
created_layouts = await _run_template_generation_thread(
_generate_indexed_slide_layouts,
raw_layouts,
indices,
slide_image_urls,
_get_template_fonts(template),
)
except (ValidationError, ValueError) as exc:
LOGGER.exception(
"[templates.v2.layouts.create] slide layout creation produced "
"invalid output template_id=%s slides=%s",
request.template_id,
",".join(str(index + 1) for index in indices),
)
raise HTTPException(
status_code=500,
detail="Slide layout creation produced invalid output",
) from exc
LOGGER.info(
"[templates.v2.layouts.create] slide layout creation complete "
"template_id=%s slides=%s components=%d",
request.template_id,
",".join(str(index + 1) for index in indices),
sum(len(item.layout.components) for item in created_layouts),
)
return CreateTemplateV2LayoutsResponse(layouts=created_layouts)
@TEMPLATES_ROUTER.post(
"/generate-blocks",
response_model=TemplateV2Response,
)
async def generate_template_v2_blocks(
request: GenerateTemplateV2BlocksRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
template = await sql_session.get(TemplateV2, request.template_id)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
if template.layouts is None:
raise HTTPException(
status_code=400,
detail="Template layouts are unavailable",
)
try:
layouts = _coerce_template_slide_layouts(template.layouts)
except ValidationError as exc:
LOGGER.exception(
"[templates.v2.generate_blocks] template has invalid layouts "
"template_id=%s",
request.template_id,
)
raise HTTPException(
status_code=500,
detail="Template layouts are invalid",
) from exc
merged_components = await _merge_generated_components(layouts)
template.merged_components = merged_components.model_dump(
mode="json",
exclude_none=True,
)
sql_session.add(template)
await sql_session.commit()
await sql_session.refresh(template)
LOGGER.info(
"[templates.v2.generate_blocks] component blocks generated "
"template_id=%s layouts=%d merged_components=%d",
request.template_id,
len(layouts.layouts),
len(merged_components.components),
)
return template
@TEMPLATES_ROUTER.patch(
"/{template_id}/layouts",
response_model=TemplateV2Response,
)
async def patch_template_v2_slide_layout(
template_id: str = Path(...),
request: PatchTemplateV2SlideLayoutRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
lock = await _get_template_layout_patch_lock(template_id)
async with lock:
template = await sql_session.get(TemplateV2, template_id)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
try:
updated_layouts, layout_indexes = _merge_template_layout_items(
template,
request.layout_items,
)
except ValidationError as exc:
LOGGER.exception(
"[templates.v2.patch_layout] template has invalid layouts "
"template_id=%s",
template_id,
)
raise HTTPException(
status_code=500,
detail="Template layouts are invalid",
) from exc
assets = dict(template.assets) if isinstance(template.assets, dict) else {}
assets["layout_indexes"] = layout_indexes
template.layouts = updated_layouts.model_dump(mode="json", exclude_none=True)
template.assets = assets
sql_session.add(template)
await sql_session.commit()
await sql_session.refresh(template)
LOGGER.info(
"[templates.v2.patch_layout] slide layouts patched template_id=%s "
"slides=%s saved_layouts=%d",
template_id,
",".join(str(item.index + 1) for item in request.layout_items),
len(updated_layouts.layouts),
)
return template
@TEMPLATES_ROUTER.patch("/{template_id}", response_model=TemplateV2Response)
async def update_template_v2_metadata(
template_id: str = Path(...),
request: UpdateTemplateV2MetadataRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
template = await sql_session.get(TemplateV2, template_id)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
if "name" in request.model_fields_set:
name = (request.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Template name is required")
template.name = name
if "description" in request.model_fields_set:
description = (request.description or "").strip()
template.description = description or None
sql_session.add(template)
await sql_session.commit()
await sql_session.refresh(template)
return template
@TEMPLATES_ROUTER.get("/{template_id}", response_model=TemplateV2Response)
async def get_template_v2(
template_id: str = Path(...),
sql_session: AsyncSession = Depends(get_async_session),
):
template = await sql_session.get(TemplateV2, template_id)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
return template
@TEMPLATES_ROUTER.delete("/{template_id}", status_code=204)
async def delete_template_v2(
template_id: str = Path(...),
sql_session: AsyncSession = Depends(get_async_session),
):
template = await sql_session.get(TemplateV2, template_id)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
await sql_session.delete(template)
await sql_session.commit()
return Response(status_code=204)