"""Public templates gallery endpoints. No authentication required.""" import logging from typing import Annotated, Any from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.database import get_db from app.core.rate_limit import limiter from app.models.category import TreeCategory from app.models.script_template import ScriptCategory, ScriptTemplate from app.models.tag import TreeTag from app.models.tree import Tree from app.schemas.public_templates import ( PublicFlowDetail, PublicFlowTemplate, PublicGalleryResponse, PublicScriptDetail, PublicScriptTemplate, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/public/templates", tags=["public-gallery"]) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _count_tree_steps(structure: dict[str, Any] | None) -> int: """Count nodes in a tree structure via BFS.""" if not structure: return 0 count = 0 queue = [structure] while queue: node = queue.pop() count += 1 for child in node.get("children", []): queue.append(child) for option in node.get("options", []): if isinstance(option, dict) and "children" in option: for child in option["children"]: queue.append(child) return count def _truncate_structure(structure: dict[str, Any] | None, max_depth: int = 3) -> dict[str, Any] | None: """Return a copy of the tree structure truncated to max_depth levels.""" if not structure: return None def _truncate_node(node: dict[str, Any], depth: int) -> dict[str, Any]: if depth >= max_depth: # Return node skeleton without children return {k: v for k, v in node.items() if k not in ("children", "options")} result = {k: v for k, v in node.items() if k not in ("children", "options")} if "children" in node: result["children"] = [_truncate_node(c, depth + 1) for c in node["children"]] if "options" in node: truncated_options = [] for opt in node["options"]: if isinstance(opt, dict): opt_copy = {k: v for k, v in opt.items() if k != "children"} if "children" in opt: opt_copy["children"] = [_truncate_node(c, depth + 1) for c in opt["children"]] truncated_options.append(opt_copy) else: truncated_options.append(opt) result["options"] = truncated_options return result return _truncate_node(structure, 0) def _build_flow_template(tree: Tree) -> PublicFlowTemplate: category_name = None if tree.category_rel: category_name = tree.category_rel.name elif tree.category: category_name = tree.category tag_names = [tag.name for tag in (tree.tags or [])] return PublicFlowTemplate( id=tree.id, name=tree.name, description=tree.description, category=category_name, tree_type=tree.tree_type, step_count=_count_tree_steps(tree.tree_structure), usage_count=tree.usage_count, success_rate=tree.success_rate, tags=tag_names, preview_structure=_truncate_structure(tree.tree_structure), created_at=tree.created_at, ) def _build_script_template(script: ScriptTemplate) -> PublicScriptTemplate: param_count = 0 if isinstance(script.parameters_schema, dict): params = script.parameters_schema.get("parameters", script.parameters_schema.get("properties", {})) if isinstance(params, list): param_count = len(params) elif isinstance(params, dict): param_count = len(params) return PublicScriptTemplate( id=script.id, name=script.name, description=script.description, category_name=script.category.name if script.category else None, category_icon=script.category.icon if script.category else None, complexity=script.complexity, tags=list(script.tags) if script.tags else [], parameter_count=param_count, requires_elevation=script.requires_elevation, requires_modules=list(script.requires_modules) if script.requires_modules else [], usage_count=script.usage_count, is_verified=script.is_verified, created_at=script.created_at, ) # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get("", response_model=PublicGalleryResponse) @limiter.limit("30/minute") async def list_gallery( request: Request, db: Annotated[AsyncSession, Depends(get_db)], category: str | None = Query(None, description="Filter by category name"), type: str = Query("all", description="Filter type: flows, scripts, or all"), sort: str = Query("usage", description="Sort order: usage, newest, success_rate"), page: int = Query(1, ge=1), per_page: int = Query(20, ge=1, le=100), ): """Public gallery listing. Returns featured flows and scripts. No authentication required. Rate limited to 30/minute. """ offset = (page - 1) * per_page flow_templates: list[PublicFlowTemplate] = [] script_templates: list[PublicScriptTemplate] = [] total_flows = 0 total_scripts = 0 category_names: set[str] = set() domains: list[str] = [] # --- Flow templates --- if type in ("all", "flows"): q = ( select(Tree) .options(selectinload(Tree.category_rel), selectinload(Tree.tags)) .where(Tree.is_gallery_featured == True) # noqa: E712 .where(Tree.is_active == True) # noqa: E712 .where(Tree.deleted_at == None) # noqa: E711 ) if category: q = q.join(TreeCategory, Tree.category_id == TreeCategory.id, isouter=True).where( or_(TreeCategory.name == category, Tree.category == category) ) # Count query count_q = select(func.count()).select_from(q.subquery()) total_flows = (await db.execute(count_q)).scalar_one() # Sort if sort == "newest": q = q.order_by(Tree.created_at.desc()) elif sort == "success_rate": q = q.order_by(Tree.success_rate.desc().nulls_last(), Tree.usage_count.desc()) else: # usage (default) q = q.order_by(Tree.usage_count.desc(), Tree.gallery_sort_order.asc()) q = q.offset(offset).limit(per_page) result = await db.execute(q) trees = result.scalars().all() for tree in trees: flow_templates.append(_build_flow_template(tree)) cat = None if tree.category_rel: cat = tree.category_rel.name elif tree.category: cat = tree.category if cat: category_names.add(cat) # --- Script templates --- if type in ("all", "scripts"): sq = ( select(ScriptTemplate) .options(selectinload(ScriptTemplate.category)) .where(ScriptTemplate.is_gallery_featured == True) # noqa: E712 .where(ScriptTemplate.is_active == True) # noqa: E712 ) if category: sq = sq.join(ScriptCategory, ScriptTemplate.category_id == ScriptCategory.id).where( ScriptCategory.name == category ) count_sq = select(func.count()).select_from(sq.subquery()) total_scripts = (await db.execute(count_sq)).scalar_one() if sort == "newest": sq = sq.order_by(ScriptTemplate.created_at.desc()) elif sort == "success_rate": sq = sq.order_by(ScriptTemplate.usage_count.desc()) else: sq = sq.order_by(ScriptTemplate.usage_count.desc(), ScriptTemplate.gallery_sort_order.asc()) sq = sq.offset(offset).limit(per_page) result = await db.execute(sq) scripts = result.scalars().all() for script in scripts: script_templates.append(_build_script_template(script)) if script.category: category_names.add(script.category.name) return PublicGalleryResponse( flow_templates=flow_templates, script_templates=script_templates, total_flows=total_flows, total_scripts=total_scripts, categories=sorted(category_names), domains=domains, ) @router.get("/flows/{flow_id}", response_model=PublicFlowDetail) @limiter.limit("30/minute") async def get_flow_detail( request: Request, flow_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], ): """Get a single featured flow template (preview only — tree truncated to 3 levels). No authentication required. Script body is never exposed. """ result = await db.execute( select(Tree) .options(selectinload(Tree.category_rel), selectinload(Tree.tags)) .where(Tree.id == flow_id) .where(Tree.is_gallery_featured == True) # noqa: E712 .where(Tree.is_active == True) # noqa: E712 .where(Tree.deleted_at == None) # noqa: E711 ) tree = result.scalar_one_or_none() if not tree: raise HTTPException(status_code=404, detail="Flow template not found") return _build_flow_template(tree) @router.get("/scripts/{script_id}", response_model=PublicScriptDetail) @limiter.limit("30/minute") async def get_script_detail( request: Request, script_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], ): """Get a single featured script template detail. NOTE: script_body is NEVER returned — it is behind the signup wall. Only parameter names/descriptions are included. """ result = await db.execute( select(ScriptTemplate) .options(selectinload(ScriptTemplate.category)) .where(ScriptTemplate.id == script_id) .where(ScriptTemplate.is_gallery_featured == True) # noqa: E712 .where(ScriptTemplate.is_active == True) # noqa: E712 ) script = result.scalar_one_or_none() if not script: raise HTTPException(status_code=404, detail="Script template not found") # Build safe parameter list (no script_body — that stays locked behind auth) safe_params: list[dict[str, Any]] = [] schema = script.parameters_schema or {} raw_params = schema.get("parameters", schema.get("properties", {})) if isinstance(raw_params, list): for p in raw_params: if isinstance(p, dict): safe_params.append({ "name": p.get("name", ""), "description": p.get("description", ""), "type": p.get("type", "string"), "required": p.get("required", False), "default": p.get("default"), }) elif isinstance(raw_params, dict): for param_name, param_def in raw_params.items(): if isinstance(param_def, dict): safe_params.append({ "name": param_name, "description": param_def.get("description", ""), "type": param_def.get("type", "string"), "required": param_def.get("required", False), "default": param_def.get("default"), }) return PublicScriptDetail( id=script.id, name=script.name, description=script.description, category_name=script.category.name if script.category else None, complexity=script.complexity, tags=list(script.tags) if script.tags else [], parameters=safe_params, requires_elevation=script.requires_elevation, requires_modules=list(script.requires_modules) if script.requires_modules else [], usage_count=script.usage_count, is_verified=script.is_verified, created_at=script.created_at, ) @router.get("/categories") @limiter.limit("30/minute") async def list_categories( request: Request, db: Annotated[AsyncSession, Depends(get_db)], ): """Return categories that have at least one gallery-featured item, with counts. No authentication required. """ # Flow categories flow_cat_result = await db.execute( select(TreeCategory.name, func.count(Tree.id).label("flow_count")) .join(Tree, Tree.category_id == TreeCategory.id) .where(Tree.is_gallery_featured == True) # noqa: E712 .where(Tree.is_active == True) # noqa: E712 .where(Tree.deleted_at == None) # noqa: E711 .group_by(TreeCategory.name) .order_by(TreeCategory.name) ) flow_cats = flow_cat_result.all() # Script categories script_cat_result = await db.execute( select(ScriptCategory.name, func.count(ScriptTemplate.id).label("script_count")) .join(ScriptTemplate, ScriptTemplate.category_id == ScriptCategory.id) .where(ScriptTemplate.is_gallery_featured == True) # noqa: E712 .where(ScriptTemplate.is_active == True) # noqa: E712 .group_by(ScriptCategory.name) .order_by(ScriptCategory.name) ) script_cats = script_cat_result.all() categories = [] seen: set[str] = set() for name, flow_count in flow_cats: if name not in seen: categories.append({"name": name, "flow_count": flow_count, "script_count": 0}) seen.add(name) for name, script_count in script_cats: if name in seen: for cat in categories: if cat["name"] == name: cat["script_count"] = script_count break else: categories.append({"name": name, "flow_count": 0, "script_count": script_count}) seen.add(name) return {"categories": categories} @router.get("/search") @limiter.limit("30/minute") async def search_gallery( request: Request, db: Annotated[AsyncSession, Depends(get_db)], q: str = Query(..., min_length=1, max_length=200, description="Search query"), type: str = Query("all", description="Filter type: flows, scripts, or all"), page: int = Query(1, ge=1), per_page: int = Query(20, ge=1, le=100), ): """Full-text search across featured gallery items. Searches name, description, and tags. No authentication required. """ offset = (page - 1) * per_page search_term = f"%{q.lower()}%" flow_templates: list[PublicFlowTemplate] = [] script_templates: list[PublicScriptTemplate] = [] total_flows = 0 total_scripts = 0 if type in ("all", "flows"): flow_q = ( select(Tree) .options(selectinload(Tree.category_rel), selectinload(Tree.tags)) .where(Tree.is_gallery_featured == True) # noqa: E712 .where(Tree.is_active == True) # noqa: E712 .where(Tree.deleted_at == None) # noqa: E711 .where( or_( func.lower(Tree.name).like(search_term), func.lower(Tree.description).like(search_term), func.lower(Tree.category).like(search_term), ) ) .order_by(Tree.usage_count.desc()) ) count_q = select(func.count()).select_from(flow_q.subquery()) total_flows = (await db.execute(count_q)).scalar_one() result = await db.execute(flow_q.offset(offset).limit(per_page)) for tree in result.scalars().all(): flow_templates.append(_build_flow_template(tree)) if type in ("all", "scripts"): script_q = ( select(ScriptTemplate) .options(selectinload(ScriptTemplate.category)) .where(ScriptTemplate.is_gallery_featured == True) # noqa: E712 .where(ScriptTemplate.is_active == True) # noqa: E712 .where( or_( func.lower(ScriptTemplate.name).like(search_term), func.lower(ScriptTemplate.description).like(search_term), ) ) .order_by(ScriptTemplate.usage_count.desc()) ) count_sq = select(func.count()).select_from(script_q.subquery()) total_scripts = (await db.execute(count_sq)).scalar_one() result = await db.execute(script_q.offset(offset).limit(per_page)) for script in result.scalars().all(): script_templates.append(_build_script_template(script)) return { "flow_templates": flow_templates, "script_templates": script_templates, "total_flows": total_flows, "total_scripts": total_scripts, "query": q, }