bmc_hub/app/modules/task_templates/backend/router.py

665 lines
22 KiB
Python
Raw Permalink Normal View History

"""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