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)