import json import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from app.core.auth_dependencies import get_current_user, require_any_permission from app.core.database import execute_query, execute_query_single from app.models.schemas import Solution, SolutionCreate, SolutionUpdate logger = logging.getLogger(__name__) router = APIRouter() case_edit_access = require_any_permission("cases.edit", "tickets.edit") VISIBILITIES = {"internal", "general", "customer"} APPROVAL_STATUSES = {"draft", "pending", "approved", "outdated", "rejected"} RESULT_ALIASES = {"resolved": "Løst", "partial": "Delvist", "unresolved": "Ej løst", "løst": "Løst", "delvist": "Delvist", "workaround": "Workaround", "ej løst": "Ej løst"} TYPE_ALIASES = {"standard": "Support", "permanent": "Support", "external": "Ekstern", "support": "Support", "drift": "Drift", "konsulent": "Konsulent", "infrastruktur": "Infrastruktur", "workaround": "Support"} def _user_id(current_user: dict) -> Optional[int]: value = current_user.get("id") or current_user.get("user_id") return int(value) if value is not None else None def _clean_list(values) -> list[str]: cleaned, seen = [], set() for value in values or []: item = str(value or "").strip() key = item.casefold() if item and key not in seen: seen.add(key) cleaned.append(item[:100]) return cleaned[:30] def _normalize_payload(data: dict) -> dict: if "title" in data: data["title"] = str(data.get("title") or "").strip() if not data["title"]: raise HTTPException(status_code=422, detail="Løsningen skal have en titel") if "visibility" in data: data["visibility"] = str(data.get("visibility") or "internal").lower() if data["visibility"] not in VISIBILITIES: raise HTTPException(status_code=422, detail="Ugyldig synlighed") if "approval_status" in data: data["approval_status"] = str(data.get("approval_status") or "draft").lower() if data["approval_status"] not in APPROVAL_STATUSES: raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus") if data.get("result") is not None: raw = str(data["result"]).strip() data["result"] = RESULT_ALIASES.get(raw.casefold(), raw) if data.get("solution_type") is not None: raw = str(data["solution_type"]).strip() data["solution_type"] = TYPE_ALIASES.get(raw.casefold(), raw) for field in ("tags", "products"): if field in data: data[field] = _clean_list(data[field]) return data def _version_solution(solution: dict, user_id: Optional[int], change_note: Optional[str] = None) -> None: version_row = execute_query_single("SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM sag_solution_versions WHERE solution_id = %s", (solution["id"],)) or {"next_version": 1} snapshot = dict(solution) for key, value in list(snapshot.items()): if hasattr(value, "isoformat"): snapshot[key] = value.isoformat() execute_query( "INSERT INTO sag_solution_versions (solution_id, version_number, snapshot, changed_by_user_id, change_note) VALUES (%s,%s,%s::jsonb,%s,%s)", (solution["id"], version_row["next_version"], json.dumps(snapshot), user_id, change_note), ) @router.get("/sag/{sag_id}/solution", response_model=Optional[Solution]) async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)): result = execute_query("SELECT * FROM sag_solutions WHERE sag_id = %s", (sag_id,)) return result[0] if result else None @router.get("/sag/{sag_id}/solution/versions") async def get_solution_versions(sag_id: int, _current_user: dict = Depends(get_current_user)): solution = execute_query_single("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,)) if not solution: return {"items": [], "total": 0} items = execute_query( """SELECT v.id, v.version_number, v.change_note, v.created_at, COALESCE(u.full_name, u.username) AS changed_by FROM sag_solution_versions v LEFT JOIN users u ON u.user_id=v.changed_by_user_id WHERE v.solution_id=%s ORDER BY v.version_number DESC""", (solution["id"],), ) or [] return {"items": items, "total": len(items)} @router.post("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)]) async def create_solution(sag_id: int, solution: SolutionCreate, current_user: dict = Depends(get_current_user)): if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)): raise HTTPException(status_code=404, detail="Sagen findes ikke") if execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s", (sag_id,)): raise HTTPException(status_code=409, detail="Der findes allerede en løsning på sagen") data = _normalize_payload(solution.model_dump(exclude={"sag_id", "created_by_user_id"})) user_id = _user_id(current_user) result = execute_query( """INSERT INTO sag_solutions ( sag_id,title,description,solution_type,result,problem,root_cause,investigation,workaround, visibility,approval_status,is_final,tags,products,created_by_user_id,updated_by_user_id ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s) RETURNING *""", (sag_id, data["title"], data.get("description"), data.get("solution_type"), data.get("result"), data.get("problem"), data.get("root_cause"), data.get("investigation"), data.get("workaround"), data.get("visibility", "internal"), data.get("approval_status", "draft"), data.get("is_final", True), json.dumps(data.get("tags", [])), json.dumps(data.get("products", [])), user_id, user_id), ) created = result[0] _version_solution(created, user_id, "Løsning oprettet") return created @router.patch("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)]) async def update_solution(sag_id: int, updates: SolutionUpdate, current_user: dict = Depends(get_current_user)): if not execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s", (sag_id,)): raise HTTPException(status_code=404, detail="Løsningen findes ikke") data = _normalize_payload(updates.model_dump(exclude_unset=True)) change_note = data.pop("change_note", None) allowed = {"title", "description", "solution_type", "result", "problem", "root_cause", "investigation", "workaround", "visibility", "approval_status", "is_final", "tags", "products"} fields, params = [], [] for key, value in data.items(): if key not in allowed: continue fields.append(f"{key} = %s" + ("::jsonb" if key in {"tags", "products"} else "")) params.append(json.dumps(value) if key in {"tags", "products"} else value) if not fields: raise HTTPException(status_code=400, detail="Ingen ændringer at gemme") user_id = _user_id(current_user) fields.extend(["updated_by_user_id = %s", "updated_at = NOW()"]) params.extend([user_id, sag_id]) updated = execute_query(f"UPDATE sag_solutions SET {', '.join(fields)} WHERE sag_id=%s RETURNING *", tuple(params))[0] _version_solution(updated, user_id, change_note or "Løsning redigeret") return updated @router.post("/sag/{sag_id}/solution/workflow", dependencies=[Depends(case_edit_access)]) async def solution_workflow(sag_id: int, request: Request, current_user: dict = Depends(get_current_user)): action = str((await request.json()).get("action") or "").lower() solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s", (sag_id,)) if not solution: raise HTTPException(status_code=404, detail="Løsningen findes ikke") user_id = _user_id(current_user) if action == "submit": status = "pending" elif action == "approve": if not str(solution.get("description") or "").strip(): raise HTTPException(status_code=422, detail="Beskriv den endelige løsning før godkendelse") status = "approved" elif action in {"reject", "outdate"}: status = "rejected" if action == "reject" else "outdated" else: raise HTTPException(status_code=422, detail="Ukendt handling") if status == "approved": updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=%s,approved_at=NOW(),updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, user_id, sag_id)) else: updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=NULL,approved_at=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, sag_id)) _version_solution(updated, user_id, f"Status ændret til {status}") return updated @router.post("/sag/{sag_id}/solution/publish", dependencies=[Depends(case_edit_access)]) async def publish_solution(sag_id: int, current_user: dict = Depends(get_current_user)): solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s", (sag_id,)) if not solution: raise HTTPException(status_code=404, detail="Løsningen findes ikke") if solution.get("approval_status") != "approved": raise HTTPException(status_code=409, detail="Løsningen skal godkendes før udgivelse") customer_id = None if solution.get("visibility") == "customer": customer = execute_query_single("SELECT customer_id FROM sag_kunder WHERE sag_id=%s AND deleted_at IS NULL ORDER BY id LIMIT 1", (sag_id,)) if not customer: raise HTTPException(status_code=409, detail="Kundespecifik viden kræver en kunde på sagen") customer_id = customer["customer_id"] description = str(solution.get("description") or "").strip() summary = description[:300] + ("…" if len(description) > 300 else "") article = execute_query_single( """INSERT INTO knowledge_articles ( solution_id,sag_id,customer_id,title,summary,problem,root_cause,investigation,solution,workaround, visibility,status,tags,products,published_by_user_id,reviewed_at ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'published',%s::jsonb,%s::jsonb,%s,NOW()) ON CONFLICT (solution_id) DO UPDATE SET customer_id=EXCLUDED.customer_id,title=EXCLUDED.title, summary=EXCLUDED.summary,problem=EXCLUDED.problem,root_cause=EXCLUDED.root_cause, investigation=EXCLUDED.investigation,solution=EXCLUDED.solution,workaround=EXCLUDED.workaround, visibility=EXCLUDED.visibility,tags=EXCLUDED.tags,products=EXCLUDED.products,status='published', version_number=knowledge_articles.version_number+1,published_by_user_id=EXCLUDED.published_by_user_id, reviewed_at=NOW(),updated_at=NOW() RETURNING *""", (solution["id"], sag_id, customer_id, solution["title"], summary, solution.get("problem"), solution.get("root_cause"), solution.get("investigation"), description, solution.get("workaround"), solution.get("visibility", "internal"), json.dumps(solution.get("tags") or []), json.dumps(solution.get("products") or []), _user_id(current_user)), ) return article @router.get("/knowledge/articles") async def search_knowledge_articles(q: str = Query("", max_length=200), customer_id: Optional[int] = None, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0), _current_user: dict = Depends(get_current_user)): term = q.strip() scope = "(ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))" if customer_id else "ka.visibility IN ('general','internal')" params: list = [customer_id] if customer_id else [] search = "" if term: search = " AND (ka.search_document @@ websearch_to_tsquery('simple', %s) OR ka.title ILIKE %s)" params.extend([term, f"%{term}%"]) count = execute_query_single(f"SELECT COUNT(*) AS total FROM knowledge_articles ka WHERE ka.status='published' AND {scope}{search}", tuple(params)) or {"total": 0} item_params = [] rank_expr = "ts_rank_cd(ka.search_document, websearch_to_tsquery('simple', %s))" if term else "0" if term: item_params.append(term) item_params.extend(params) item_params.extend([limit, offset]) items = execute_query( f"""SELECT ka.id,ka.title,ka.summary,ka.visibility,ka.customer_id,ka.tags,ka.products, ka.version_number,ka.updated_at,ka.sag_id,{rank_expr} AS relevance,c.name AS customer_name FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id WHERE ka.status='published' AND {scope}{search} ORDER BY relevance DESC,ka.updated_at DESC,ka.id DESC LIMIT %s OFFSET %s""", tuple(item_params), ) or [] return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset} @router.get("/knowledge/articles/{article_id}") async def get_knowledge_article(article_id: int, _current_user: dict = Depends(get_current_user)): article = execute_query_single( """SELECT ka.*,c.name AS customer_name,COALESCE(u.full_name,u.username) AS published_by FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id LEFT JOIN users u ON u.user_id=ka.published_by_user_id WHERE ka.id=%s AND ka.status='published'""", (article_id,), ) if not article: raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke") return article