- Added multiple test cases for the sag module to ensure proper functionality and data handling. - Created new templates for knowledge detail and knowledge index pages to display articles and solutions. - Introduced migrations to enhance the internet connections schema, including new columns for manual sharing and SLA subscriptions. - Added a script to reconcile known internet connections with verified data. - Planned the implementation of a new website content administration module for managing customer references and operational status.
232 lines
13 KiB
Python
232 lines
13 KiB
Python
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
|