"""Task template backend API (MVP).""" from datetime import date, datetime, timedelta from typing import Any, Dict, List, Optional, Tuple import logging from fastapi import APIRouter, Depends, HTTPException, Query from app.core.auth_dependencies import require_permission from app.core.database import execute_query, execute_query_single from app.modules.task_templates.backend.models import ( TaskTemplate, TaskTemplateCreate, TaskTemplateItem, TaskTemplateItemCreate, TaskTemplateItemUpdate, TaskTemplateUpdate, TemplatePreviewRequest, TemplateRunRequest, ) router = APIRouter() logger = logging.getLogger(__name__) def _validate_company_template_payload(template_type: str, customer_id: Optional[int]) -> None: if template_type == "company" and not customer_id: raise HTTPException(status_code=400, detail="customer_id is required for company templates") if template_type in ("global", "internal", "deactivated") and customer_id is not None: raise HTTPException( status_code=400, detail="customer_id must be null unless template_type is 'company'", ) def _fetch_case(case_id: int) -> Dict[str, Any]: case_row = execute_query_single( """ SELECT id, titel, customer_id, ansvarlig_bruger_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL """, (case_id,), ) if not case_row: raise HTTPException(status_code=404, detail="Case not found") return case_row def _fetch_template_for_case(template_id: int, case_customer_id: Optional[int]) -> Dict[str, Any]: template = execute_query_single( """ SELECT t.* FROM task_templates t WHERE t.id = %s AND t.deleted_at IS NULL AND t.is_active = TRUE AND t.template_type != 'deactivated' AND ( t.template_type IN ('global', 'internal') OR (t.template_type = 'company' AND t.customer_id = %s) ) """, (template_id, case_customer_id), ) if not template: raise HTTPException(status_code=404, detail="Template not available for this case") return template def _fetch_template_items(template_id: int) -> List[Dict[str, Any]]: return execute_query( """ SELECT * FROM task_template_items WHERE template_id = %s AND is_active = TRUE ORDER BY sort_order ASC, id ASC """, (template_id,), ) or [] def _resolve_assignee( item: Dict[str, Any], assignee_mode: str, assignee_user_id: Optional[int], assignee_role_id: Optional[int], ) -> Tuple[Optional[int], Optional[int]]: if assignee_mode == "specific_user": return assignee_user_id, None if assignee_mode == "specific_role": return None, assignee_role_id return item.get("default_assignee_user_id"), item.get("default_assignee_role_id") def _build_preview( items: List[Dict[str, Any]], start_date: date, mode: str, assignee_mode: str, assignee_user_id: Optional[int], assignee_role_id: Optional[int], ) -> Dict[str, Any]: preview_items: List[Dict[str, Any]] = [] summary = { "subcases": 0, "tasks": 0, "assignments": 0, "deadlines": 0, } for item in items: item_type = item.get("item_type") if mode == "tasks" and item_type != "task": continue if mode == "subcases" and item_type != "subcase": continue days_offset = int(item.get("days_offset") or 0) due_date = start_date + timedelta(days=days_offset) assigned_user_id, assigned_role_id = _resolve_assignee( item, assignee_mode, assignee_user_id, assignee_role_id, ) preview_items.append( { "template_item_id": item.get("id"), "title": item.get("title"), "description": item.get("description"), "item_type": item_type, "sort_order": item.get("sort_order") or 0, "days_offset": days_offset, "planned_due_date": due_date.isoformat(), "is_required": bool(item.get("is_required")), "assigned_user_id": assigned_user_id, "assigned_role_id": assigned_role_id, } ) if item_type == "subcase": summary["subcases"] += 1 else: summary["tasks"] += 1 if assigned_user_id or assigned_role_id: summary["assignments"] += 1 summary["deadlines"] += 1 return {"items": preview_items, "summary": summary} @router.get("/task-templates", response_model=List[TaskTemplate]) async def list_task_templates( company_id: Optional[int] = Query(None), category: Optional[str] = Query(None), source: str = Query("all", description="all|company|global|internal"), current_user: dict = Depends(require_permission("templates.view")), ): del current_user where_parts = ["t.deleted_at IS NULL", "t.is_active = TRUE", "t.template_type != 'deactivated'"] params: List[Any] = [] if category: where_parts.append("t.category = %s") params.append(category) if source == "company": where_parts.append("t.template_type = 'company'") elif source == "global": where_parts.append("t.template_type = 'global'") elif source == "internal": where_parts.append("t.template_type = 'internal'") elif source != "all": raise HTTPException(status_code=400, detail="Invalid source. Use all|company|global|internal") if company_id is not None: where_parts.append( "(t.template_type IN ('global', 'internal') OR (t.template_type = 'company' AND t.customer_id = %s))" ) params.append(company_id) where_clause = " AND ".join(where_parts) query = f""" SELECT t.id, t.name, t.description, t.template_type, t.customer_id, t.category, t.is_active, t.created_by, t.created_at, t.updated_at FROM task_templates t WHERE {where_clause} ORDER BY CASE WHEN %s IS NOT NULL AND t.template_type = 'company' AND t.customer_id = %s THEN 0 WHEN t.template_type = 'company' THEN 1 WHEN t.template_type = 'global' THEN 2 ELSE 3 END, t.name ASC """ params.extend([company_id, company_id]) rows = execute_query(query, tuple(params)) or [] return [TaskTemplate(**row) for row in rows] @router.post("/task-templates", response_model=TaskTemplate) async def create_task_template( payload: TaskTemplateCreate, current_user: dict = Depends(require_permission("templates.create")), ): _validate_company_template_payload(payload.template_type, payload.customer_id) created = execute_query_single( """ INSERT INTO task_templates (name, description, template_type, customer_id, category, is_active, created_by) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id, name, description, template_type, customer_id, category, is_active, created_by, created_at, updated_at """, ( payload.name.strip(), payload.description, payload.template_type, payload.customer_id, payload.category, payload.is_active, current_user.get("id"), ), ) if not created: raise HTTPException(status_code=500, detail="Failed to create task template") return TaskTemplate(**created) @router.patch("/task-templates/{template_id}", response_model=TaskTemplate) async def update_task_template( template_id: int, payload: TaskTemplateUpdate, current_user: dict = Depends(require_permission("templates.edit")), ): del current_user current = execute_query_single( "SELECT * FROM task_templates WHERE id = %s AND deleted_at IS NULL", (template_id,), ) if not current: raise HTTPException(status_code=404, detail="Template not found") new_template_type = payload.template_type or current.get("template_type") if new_template_type in ("global", "internal", "deactivated"): new_customer_id = None elif payload.customer_id is not None: new_customer_id = payload.customer_id else: new_customer_id = current.get("customer_id") _validate_company_template_payload(new_template_type, new_customer_id) updates = [] params: List[Any] = [] if payload.name is not None: updates.append("name = %s") params.append(payload.name.strip()) if payload.description is not None: updates.append("description = %s") params.append(payload.description) if payload.template_type is not None: updates.append("template_type = %s") params.append(payload.template_type) if payload.customer_id is not None or payload.template_type in ("global", "internal", "deactivated"): updates.append("customer_id = %s") params.append(new_customer_id) if payload.category is not None: updates.append("category = %s") params.append(payload.category) if payload.is_active is not None: updates.append("is_active = %s") params.append(payload.is_active) if not updates: raise HTTPException(status_code=400, detail="No fields to update") updates.append("updated_at = NOW()") params.append(template_id) updated = execute_query_single( f""" UPDATE task_templates SET {', '.join(updates)} WHERE id = %s AND deleted_at IS NULL RETURNING id, name, description, template_type, customer_id, category, is_active, created_by, created_at, updated_at """, tuple(params), ) if not updated: raise HTTPException(status_code=404, detail="Template not found") return TaskTemplate(**updated) @router.delete("/task-templates/{template_id}") async def deactivate_task_template( template_id: int, current_user: dict = Depends(require_permission("templates.delete")), ): del current_user row = execute_query_single( """ UPDATE task_templates SET is_active = FALSE, template_type = 'deactivated', customer_id = NULL, updated_at = NOW() WHERE id = %s AND deleted_at IS NULL RETURNING id """, (template_id,), ) if not row: raise HTTPException(status_code=404, detail="Template not found") return {"status": "deactivated", "template_id": template_id} @router.get("/task-templates/{template_id}/items", response_model=List[TaskTemplateItem]) async def list_task_template_items( template_id: int, current_user: dict = Depends(require_permission("templates.view")), ): del current_user rows = _fetch_template_items(template_id) return [TaskTemplateItem(**row) for row in rows] @router.post("/task-templates/{template_id}/items", response_model=TaskTemplateItem) async def create_task_template_item( template_id: int, payload: TaskTemplateItemCreate, current_user: dict = Depends(require_permission("templates.edit")), ): del current_user template_exists = execute_query_single( "SELECT id FROM task_templates WHERE id = %s AND deleted_at IS NULL", (template_id,), ) if not template_exists: raise HTTPException(status_code=404, detail="Template not found") created = execute_query_single( """ INSERT INTO task_template_items ( template_id, title, description, item_type, default_assignee_user_id, default_assignee_role_id, days_offset, sort_order, is_required, is_active ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id, template_id, title, description, item_type, default_assignee_user_id, default_assignee_role_id, days_offset, sort_order, is_required, is_active, created_at, updated_at """, ( template_id, payload.title.strip(), payload.description, payload.item_type, payload.default_assignee_user_id, payload.default_assignee_role_id, payload.days_offset, payload.sort_order, payload.is_required, payload.is_active, ), ) if not created: raise HTTPException(status_code=500, detail="Failed to create template item") return TaskTemplateItem(**created) @router.patch("/task-template-items/{item_id}", response_model=TaskTemplateItem) async def update_task_template_item( item_id: int, payload: TaskTemplateItemUpdate, current_user: dict = Depends(require_permission("templates.edit")), ): del current_user updates = [] params: List[Any] = [] if payload.title is not None: updates.append("title = %s") params.append(payload.title.strip()) if payload.description is not None: updates.append("description = %s") params.append(payload.description) if payload.item_type is not None: updates.append("item_type = %s") params.append(payload.item_type) if payload.default_assignee_user_id is not None: updates.append("default_assignee_user_id = %s") params.append(payload.default_assignee_user_id) if payload.default_assignee_role_id is not None: updates.append("default_assignee_role_id = %s") params.append(payload.default_assignee_role_id) if payload.days_offset is not None: updates.append("days_offset = %s") params.append(payload.days_offset) if payload.sort_order is not None: updates.append("sort_order = %s") params.append(payload.sort_order) if payload.is_required is not None: updates.append("is_required = %s") params.append(payload.is_required) if payload.is_active is not None: updates.append("is_active = %s") params.append(payload.is_active) if not updates: raise HTTPException(status_code=400, detail="No fields to update") updates.append("updated_at = NOW()") params.append(item_id) row = execute_query_single( f""" UPDATE task_template_items SET {', '.join(updates)} WHERE id = %s RETURNING id, template_id, title, description, item_type, default_assignee_user_id, default_assignee_role_id, days_offset, sort_order, is_required, is_active, created_at, updated_at """, tuple(params), ) if not row: raise HTTPException(status_code=404, detail="Template item not found") return TaskTemplateItem(**row) @router.post("/cases/{case_id}/template-preview") async def preview_template_for_case( case_id: int, payload: TemplatePreviewRequest, current_user: dict = Depends(require_permission("templates.view")), ): del current_user case_row = _fetch_case(case_id) template = _fetch_template_for_case(payload.template_id, case_row.get("customer_id")) start_date = payload.start_date or date.today() items = _fetch_template_items(payload.template_id) preview = _build_preview( items, start_date, payload.mode, payload.assignee_mode, payload.assignee_user_id, payload.assignee_role_id, ) return { "case_id": case_id, "template": { "id": template.get("id"), "name": template.get("name"), "template_type": template.get("template_type"), "category": template.get("category"), }, "mode": payload.mode, "assignee_mode": payload.assignee_mode, "start_date": start_date.isoformat(), "summary": preview["summary"], "items": preview["items"], } @router.post("/cases/{case_id}/run-template") async def run_template_for_case( case_id: int, payload: TemplateRunRequest, current_user: dict = Depends(require_permission("templates.run")), ): case_row = _fetch_case(case_id) template = _fetch_template_for_case(payload.template_id, case_row.get("customer_id")) start_date = payload.start_date or date.today() items = _fetch_template_items(payload.template_id) preview = _build_preview( items, start_date, payload.mode, payload.assignee_mode, payload.assignee_user_id, payload.assignee_role_id, ) run_row = execute_query_single( """ INSERT INTO case_template_runs (case_id, template_id, started_by, started_at, status) VALUES (%s, %s, %s, NOW(), 'running') RETURNING id """, (case_id, payload.template_id, current_user.get("id")), ) if not run_row: raise HTTPException(status_code=500, detail="Failed to start template run") run_id = run_row.get("id") created_task_ids: List[int] = [] created_case_ids: List[int] = [] try: for item in preview["items"]: created_task_id = None created_case_id = None if item["item_type"] == "task": task_row = execute_query_single( """ INSERT INTO sag_todo_steps (sag_id, title, description, due_date, created_by_user_id) VALUES (%s, %s, %s, %s, %s) RETURNING id """, ( case_id, item["title"], item.get("description"), item.get("planned_due_date"), current_user.get("id"), ), ) created_task_id = task_row.get("id") if task_row else None if created_task_id: created_task_ids.append(created_task_id) elif item["item_type"] == "subcase": subcase_row = execute_query_single( """ INSERT INTO sag_sager ( titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline ) VALUES (%s, %s, %s, 'åben', %s, %s, %s, %s::date + time '09:00') RETURNING id """, ( item["title"], item.get("description"), "task_template", case_row.get("customer_id"), case_row.get("ansvarlig_bruger_id"), current_user.get("id"), item.get("planned_due_date"), ), ) created_case_id = subcase_row.get("id") if subcase_row else None if created_case_id: created_case_ids.append(created_case_id) execute_query( """ INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype) VALUES (%s, %s, %s) """, (case_id, created_case_id, "undersag"), ) execute_query( """ INSERT INTO case_template_run_items ( template_run_id, template_item_id, created_case_id, created_task_id, status ) VALUES (%s, %s, %s, %s, %s) """, ( run_id, item.get("template_item_id"), created_case_id, created_task_id, "created" if (created_case_id or created_task_id) else "skipped", ), ) execute_query( "UPDATE case_template_runs SET status = 'completed', updated_at = NOW() WHERE id = %s", (run_id,), ) except Exception as exc: logger.error("❌ Template run %s failed: %s", run_id, exc) execute_query( "UPDATE case_template_runs SET status = 'failed', error_message = %s, updated_at = NOW() WHERE id = %s", (str(exc), run_id), ) raise HTTPException(status_code=500, detail="Template run failed") return { "status": "completed", "run_id": run_id, "case_id": case_id, "template": { "id": template.get("id"), "name": template.get("name"), }, "summary": preview["summary"], "created": { "task_ids": created_task_ids, "subcase_ids": created_case_ids, }, "links": { "original_case_id": case_id, "related_subcases": created_case_ids, }, } @router.get("/cases/{case_id}/template-runs") async def list_template_runs_for_case( case_id: int, limit: int = Query(10, ge=1, le=50), current_user: dict = Depends(require_permission("templates.view")), ): del current_user _fetch_case(case_id) rows = execute_query( """ SELECT r.id, r.case_id, r.template_id, t.name AS template_name, r.started_by, r.started_at, r.status, r.error_message, COUNT(ri.id) AS item_count, COUNT(ri.id) FILTER (WHERE ri.created_task_id IS NOT NULL) AS created_tasks, COUNT(ri.id) FILTER (WHERE ri.created_case_id IS NOT NULL) AS created_subcases FROM case_template_runs r LEFT JOIN task_templates t ON t.id = r.template_id LEFT JOIN case_template_run_items ri ON ri.template_run_id = r.id WHERE r.case_id = %s GROUP BY r.id, t.name ORDER BY r.started_at DESC, r.id DESC LIMIT %s """, (case_id, limit), ) or [] return rows