Refactor code structure for improved readability and maintainability

This commit is contained in:
Christian 2026-04-26 13:14:53 +02:00
parent dee82af2ea
commit 5bd54a27dc
5 changed files with 16938 additions and 8 deletions

View File

@ -7,7 +7,7 @@ import hashlib
import base64
import html
from pathlib import Path
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import List, Optional, Dict
from uuid import uuid4
@ -876,6 +876,14 @@ async def mark_sag_recent_open(sag_id: int, request: Request):
if not case_row:
raise HTTPException(status_code=404, detail="Case not found")
if not _table_exists("sag_recent_cases"):
logger.warning("⚠️ sag_recent_cases table missing; skipping recent-open persistence")
return {
"sag_id": sag_id,
"titel": case_row.get("titel"),
"opened_at": None,
}
upserted = execute_query(
"""
INSERT INTO sag_recent_cases (user_id, sag_id, opened_at)
@ -921,6 +929,10 @@ async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10
user_id = _get_user_id_from_request(request)
try:
if not _table_exists("sag_recent_cases"):
logger.warning("⚠️ sag_recent_cases table missing; returning empty recent cases")
return []
rows = execute_query(
"""
SELECT
@ -1163,13 +1175,18 @@ async def update_sag(sag_id: int, updates: dict):
try:
# Check if case exists
check = execute_query(
"SELECT id, status FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
"""
SELECT id, status, template_key, priority, ansvarlig_bruger_id, assigned_group_id
FROM sag_sager
WHERE id = %s AND deleted_at IS NULL
""",
(sag_id,),
)
if not check:
raise HTTPException(status_code=404, detail="Case not found")
previous_status = str((check[0] or {}).get("status") or "").strip().lower()
previous_row = check[0] or {}
previous_status = str(previous_row.get("status") or "").strip().lower()
# Backwards compatibility: frontend sends "type", DB stores "template_key"
if "type" in updates and "template_key" not in updates:
@ -1249,12 +1266,102 @@ async def update_sag(sag_id: int, updates: dict):
result = execute_query(query, tuple(params))
if result:
updated_row = result[0] or {}
if "status" in updates:
new_status = str((result[0] or {}).get("status") or "").strip().lower()
new_status = str(updated_row.get("status") or "").strip().lower()
if new_status and new_status != previous_status:
_activate_waiting_cases_by_status(sag_id, previous_status, new_status)
try:
def _norm(value: Optional[object]) -> str:
return str(value or "").strip()
def _resolve_user_name(user_id: Optional[object]) -> str:
if user_id in (None, ""):
return "Ingen"
row = execute_query_single(
"""
SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), ''), CONCAT('Bruger #', user_id::text)) AS name
FROM users
WHERE user_id = %s
""",
(int(user_id),),
)
return str((row or {}).get("name") or f"Bruger #{int(user_id)}")
def _resolve_group_name(group_id: Optional[object]) -> str:
if group_id in (None, ""):
return "Ingen"
row = execute_query_single(
"SELECT COALESCE(NULLIF(TRIM(name), ''), CONCAT('Gruppe #', id::text)) AS name FROM groups WHERE id = %s",
(int(group_id),),
)
return str((row or {}).get("name") or f"Gruppe #{int(group_id)}")
type_labels = {
"ticket": "Ticket",
"pipeline": "Pipeline",
"opgave": "Opgave",
"ordre": "Ordre",
"projekt": "Projekt",
"service": "Service",
}
change_messages: List[str] = []
if "status" in updates:
old_value = _norm(previous_row.get("status")) or "-"
new_value = _norm(updated_row.get("status")) or "-"
if old_value.lower() != new_value.lower():
change_messages.append(f"🔄 Status ændret: {old_value}{new_value}")
if "template_key" in updates or "type" in updates:
old_type_key = _norm(previous_row.get("template_key")).lower() or "ticket"
new_type_key = _norm(updated_row.get("template_key")).lower() or "ticket"
if old_type_key != new_type_key:
old_type = type_labels.get(old_type_key, old_type_key.capitalize())
new_type = type_labels.get(new_type_key, new_type_key.capitalize())
change_messages.append(f"🧩 Type ændret: {old_type}{new_type}")
if "priority" in updates:
old_priority = _norm(previous_row.get("priority") or "normal").lower()
new_priority = _norm(updated_row.get("priority") or "normal").lower()
if old_priority != new_priority:
priority_labels = {
"low": "Lav",
"normal": "Normal",
"high": "Høj",
"urgent": "Akut",
}
old_label = priority_labels.get(old_priority, old_priority.capitalize() or "-")
new_label = priority_labels.get(new_priority, new_priority.capitalize() or "-")
change_messages.append(f"⚠️ Prioritet ændret: {old_label}{new_label}")
if "ansvarlig_bruger_id" in updates:
old_user = _resolve_user_name(previous_row.get("ansvarlig_bruger_id"))
new_user = _resolve_user_name(updated_row.get("ansvarlig_bruger_id"))
if old_user != new_user:
change_messages.append(f"👤 Ansvarlig ændret: {old_user}{new_user}")
if "assigned_group_id" in updates:
old_group = _resolve_group_name(previous_row.get("assigned_group_id"))
new_group = _resolve_group_name(updated_row.get("assigned_group_id"))
if old_group != new_group:
change_messages.append(f"👥 Gruppe ændret: {old_group}{new_group}")
for message in change_messages:
execute_query(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
""",
(sag_id, "System", message, True),
)
except Exception as comment_log_error:
logger.warning("⚠️ Could not log field-change comments for case %s: %s", sag_id, comment_log_error)
logger.info("✅ Case updated: %s", sag_id)
return result[0]
return updated_row
raise HTTPException(status_code=500, detail="Failed to update case")
except HTTPException:
raise
@ -2717,6 +2824,275 @@ async def get_kommentarer(sag_id: int):
logger.error("❌ Error getting comments: %s", e)
raise HTTPException(status_code=500, detail="Failed to get comments")
@router.get("/sag/{sag_id}/timeline")
async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
"""Return a unified timeline for the case with optional child cases."""
try:
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Case not found")
if include_subcases:
case_tree_query = """
WITH RECURSIVE normalized_relations AS (
SELECT
CASE
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id
ELSE kilde_sag_id
END AS parent_id,
CASE
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id
ELSE målsag_id
END AS child_id
FROM sag_relationer
WHERE deleted_at IS NULL
),
case_tree AS (
SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL
UNION
SELECT nr.child_id
FROM normalized_relations nr
JOIN case_tree ct ON nr.parent_id = ct.id
)
SELECT s.id, s.titel
FROM sag_sager s
JOIN case_tree ct ON s.id = ct.id
WHERE s.deleted_at IS NULL
ORDER BY s.id
"""
case_rows = execute_query(case_tree_query, (sag_id,)) or []
else:
case_rows = execute_query(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,)
) or []
case_ids = [row.get("id") for row in case_rows if row.get("id") is not None]
if not case_ids:
return {"case_tree": [], "events": [], "total": 0}
placeholders = ",".join(["%s"] * len(case_ids))
comments_query = f"""
SELECT c.*, s.titel AS source_sag_titel
FROM sag_kommentarer c
LEFT JOIN sag_sager s ON s.id = c.sag_id
WHERE c.deleted_at IS NULL
AND c.sag_id IN ({placeholders})
ORDER BY c.created_at DESC
LIMIT 800
"""
comments = execute_query(comments_query, tuple(case_ids)) or []
beskrivelse_history = []
if _table_exists("sag_beskrivelse_history"):
history_query = f"""
SELECT h.id, h.sag_id, h.beskrivelse_before, h.beskrivelse_after,
h.changed_by_name, h.changed_at, s.titel AS source_sag_titel
FROM sag_beskrivelse_history h
LEFT JOIN sag_sager s ON s.id = h.sag_id
WHERE h.sag_id IN ({placeholders})
ORDER BY h.changed_at DESC
LIMIT 400
"""
beskrivelse_history = execute_query(history_query, tuple(case_ids)) or []
def _detect_source(text: Optional[str]) -> str:
value = str(text or "").lower()
if re.search(r"\bemail\b|\be-mail\b|✉️|📧|email-kladde|mail\b|indgaaende email|udgaaende email|indgående email|udgående email", value):
return "email"
if re.search(r"\bsms\b|💬", value):
return "sms"
if re.search(r"\bopkald\b|\bring\b|\bringet\b|📞|click-to-call|yealink", value):
return "call"
return "module"
def _detect_field_change_subtype(text: Optional[str]) -> Optional[str]:
value = str(text or "").lower()
if re.search(r"\bstatus\b.*\b(ændret|ændring|skiftet|opdateret|sat til)\b|\bændret status\b|\bstatus:\s*.*->|\bstatus\s*->", value):
return "status"
if re.search(r"\b(type|sagstype|template_key|template key)\b.*\b(ændret|ændring|skiftet|opdateret|sat til)\b|\bændret type\b|\btype:\s*.*->|\btemplate_key\b", value):
return "type"
if re.search(r"\b(prioritet|priority)\b.*\b(ændret|ændring|skiftet|opdateret|sat til)\b|\bændret prioritet\b|\bprioritet:\s*.*->", value):
return "priority"
if re.search(r"\b(ansvarlig|assignee|owner|ansvarlig_bruger_id|ansvarlig bruger)\b.*\b(ændret|ændring|skiftet|opdateret|sat til|tildelt)\b|\bændret ansvarlig\b|\bansvarlig:\s*.*->|\bassign(ed)? to\b", value):
return "assignee"
if re.search(r"\b(gruppe|team|assigned_group_id|assigned group|gruppe-id)\b.*\b(ændret|ændring|skiftet|opdateret|sat til|tildelt)\b|\bændret gruppe\b|\bgruppe:\s*.*->", value):
return "group"
return None
events = []
for row in comments:
created_at = row.get("created_at")
if not created_at:
continue
if isinstance(created_at, datetime):
ts = created_at.isoformat()
else:
ts = str(created_at)
is_system = bool(row.get("er_system_besked"))
is_internal = bool(row.get("er_intern"))
subtype = "system" if is_system else ("internal" if is_internal else "external")
content = str(row.get("indhold") or "")
author = str(row.get("forfatter") or "Bruger")
field_change_subtype = _detect_field_change_subtype(content)
comment_id = row.get("id") or row.get("kommentar_id") or len(events) + 1
events.append({
"id": f"comment:{comment_id}",
"event_type": "field_change" if field_change_subtype else "comment",
"event_subtype": field_change_subtype or subtype,
"source": "case" if field_change_subtype else _detect_source(content),
"timestamp": ts,
"sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"),
"forfatter": author,
"title": f"Feltændring · {author}" if field_change_subtype else f"Kommentar · {author}",
"description": content,
})
for row in beskrivelse_history:
changed_at = row.get("changed_at")
if not changed_at:
continue
if isinstance(changed_at, datetime):
ts = changed_at.isoformat()
else:
ts = str(changed_at)
changed_by = str(row.get("changed_by_name") or "Ukendt")
before = str(row.get("beskrivelse_before") or "")
after = str(row.get("beskrivelse_after") or "")
before_short = (before[:240] + "...") if len(before) > 240 else before
after_short = (after[:240] + "...") if len(after) > 240 else after
description = f"Før: {before_short or 'tom'}\nEfter: {after_short or 'tom'}"
events.append({
"id": f"description:{row.get('id') or len(events) + 1}",
"event_type": "description",
"event_subtype": "edit",
"source": "module",
"timestamp": ts,
"sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"),
"forfatter": changed_by,
"title": f"Beskrivelse opdateret · {changed_by}",
"description": description,
})
if _table_exists("sag_reminders"):
reminders_query = f"""
SELECT r.id, r.sag_id, r.title, r.message, r.event_type,
r.next_check_at, r.scheduled_at,
s.titel AS source_sag_titel
FROM sag_reminders r
LEFT JOIN sag_sager s ON s.id = r.sag_id
WHERE r.deleted_at IS NULL
AND r.sag_id IN ({placeholders})
ORDER BY COALESCE(r.next_check_at, r.scheduled_at) DESC
LIMIT 400
"""
reminders = execute_query(reminders_query, tuple(case_ids)) or []
for row in reminders:
start_value = row.get("next_check_at") or row.get("scheduled_at")
if not start_value:
continue
ts = start_value.isoformat() if isinstance(start_value, datetime) else str(start_value)
title = str(row.get("title") or "Påmindelse")
message = str(row.get("message") or "")
event_kind = str(row.get("event_type") or "reminder")
events.append({
"id": f"reminder:{row.get('id') or len(events) + 1}",
"event_type": "reminder",
"event_subtype": event_kind,
"source": "reminder",
"timestamp": ts,
"sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"),
"forfatter": "Reminder",
"title": f"Påmindelse · {title}",
"description": message,
})
if _table_exists("tmodule_times"):
time_query = f"""
SELECT t.id, t.sag_id, t.description, t.entry_type,
t.worked_date, t.created_at, t.approved_hours, t.original_hours,
s.titel AS source_sag_titel
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.sag_id IN ({placeholders})
ORDER BY COALESCE(t.worked_date::timestamp, t.created_at) DESC, t.id DESC
LIMIT 600
"""
time_entries = execute_query(time_query, tuple(case_ids)) or []
for row in time_entries:
raw_ts = row.get("worked_date") or row.get("created_at")
if not raw_ts:
continue
ts = raw_ts.isoformat() if isinstance(raw_ts, datetime) else str(raw_ts)
hours = row.get("approved_hours")
if hours is None:
hours = row.get("original_hours")
hours_text = f"{float(hours):.2f} t" if hours is not None else "ukendt tid"
description = str(row.get("description") or "")
entry_type = str(row.get("entry_type") or "arbejde")
events.append({
"id": f"time:{row.get('id') or len(events) + 1}",
"event_type": "time",
"event_subtype": entry_type,
"source": "time",
"timestamp": ts,
"sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"),
"forfatter": "Tid",
"title": f"Tidsregistrering · {hours_text}",
"description": description,
})
def _sort_key(item: Dict) -> datetime:
raw = str(item.get("timestamp") or "")
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00").replace(" ", "T"))
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
except Exception:
return datetime.min
events.sort(key=_sort_key, reverse=True)
return {
"case_tree": case_rows,
"events": events,
"total": len(events),
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error loading case timeline: %s", e)
raise HTTPException(status_code=500, detail="Failed to load case timeline")
@router.post("/sag/{sag_id}/kommentarer")
async def add_kommentar(sag_id: int, data: dict, request: Request):
"""Add a comment to a case."""

View File

@ -779,6 +779,332 @@ async def sag_detaljer(request: Request, sag_id: int):
raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
async def sag_detaljer_v3(request: Request, sag_id: int):
"""Display case details."""
try:
# Fetch main case
sag_query = """
SELECT s.*,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
WHERE s.id = %s AND s.deleted_at IS NULL
"""
sag_result = execute_query(sag_query, (sag_id,))
if not sag_result:
raise HTTPException(status_code=404, detail="Case not found")
sag = sag_result[0]
# Fetch tags (Support both Legacy sag_tags and New entity_tags)
# First try the new system (entity_tags) which the valid frontend uses
tags_query = """
SELECT t.name as tag_navn
FROM tags t
JOIN entity_tags et ON t.id = et.tag_id
WHERE et.entity_type = 'case' AND et.entity_id = %s
"""
tags = execute_query(tags_query, (sag_id,))
# If empty, try legacy table fallback
if not tags:
tags_query_legacy = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
tags = execute_query(tags_query_legacy, (sag_id,))
# Fetch relations
relationer_query = """
SELECT sr.*,
ss_kilde.titel as kilde_titel,
ss_mål.titel as mål_titel
FROM sag_relationer sr
JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id
JOIN sag_sager ss_mål ON sr.målsag_id = ss_mål.id
WHERE (sr.kilde_sag_id = %s OR sr.målsag_id = %s)
AND sr.deleted_at IS NULL
ORDER BY sr.created_at DESC
"""
relationer = execute_query(relationer_query, (sag_id, sag_id))
# --- Relation Tree Construction ---
relation_tree = []
try:
from app.modules.sag.services.relation_service import RelationService
relation_tree = RelationService.get_relation_tree(sag_id)
except Exception as e:
logger.error(f"Error building relation tree: {e}")
relation_tree = []
# Fallback: if tree builder fails/returns empty but relations exist, render a minimal flat tree.
if not relation_tree and relationer:
try:
root_node = {
"case": {
"id": sag.get("id"),
"titel": sag.get("titel"),
"status": sag.get("status"),
"type": sag.get("type"),
"template_key": sag.get("template_key"),
},
"relation_type": None,
"relation_id": None,
"is_current": True,
"children": [],
}
seen_related = set()
for rel in relationer or []:
source_id = rel.get("kilde_sag_id")
target_id = rel.get("målsag_id")
related_id = target_id if source_id == sag_id else source_id
if not related_id or related_id in seen_related:
continue
seen_related.add(related_id)
root_node["children"].append({
"case": {
"id": related_id,
"titel": rel.get("mål_titel") if source_id == sag_id else rel.get("kilde_titel"),
"status": None,
"type": None,
"template_key": None,
},
"relation_type": rel.get("relationstype") or "Relateret til",
"relation_id": rel.get("id"),
"is_current": False,
"children": [],
})
relation_tree = [root_node]
except Exception as fallback_err:
logger.warning("⚠️ Could not build fallback relation tree: %s", fallback_err)
# Fetch customer info if customer_id exists
customer = None
hovedkontakt = None
if sag.get('customer_id'):
customer_query = "SELECT * FROM customers WHERE id = %s"
customer_result = execute_query(customer_query, (sag['customer_id'],))
if customer_result:
customer = customer_result[0]
# Fetch hovedkontakt (primary contact) for case via sag_kontakter
kontakt_query = """
SELECT c.*
FROM contacts c
JOIN sag_kontakter sk ON c.id = sk.contact_id
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL AND sk.is_primary = TRUE
LIMIT 1
"""
kontakt_result = execute_query(kontakt_query, (sag_id,))
if kontakt_result:
hovedkontakt = kontakt_result[0]
else:
fallback_query = """
SELECT c.*
FROM contacts c
JOIN sag_kontakter sk ON c.id = sk.contact_id
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
ORDER BY sk.created_at ASC
LIMIT 1
"""
fallback_result = execute_query(fallback_query, (sag_id,))
if fallback_result:
hovedkontakt = fallback_result[0]
# Fetch prepaid cards for customer
# Cast remaining_hours to float to avoid Jinja formatting issues with Decimal
# DEBUG: Logging customer ID
prepaid_cards = []
if sag.get('customer_id'):
cid = sag.get('customer_id')
logger.info(f"🔎 Looking up prepaid cards for Sag {sag_id}, Customer ID: {cid} (Type: {type(cid)})")
pc_query = """
SELECT id, card_number, CAST(remaining_hours AS FLOAT) as remaining_hours, expires_at
FROM tticket_prepaid_cards
WHERE customer_id = %s
AND status = 'active'
AND remaining_hours > 0
ORDER BY created_at DESC
"""
prepaid_cards = execute_query(pc_query, (cid,))
logger.info(f"💳 Found {len(prepaid_cards)} prepaid cards for customer {cid}")
# Fetch fixed-price agreements for customer
fixed_price_agreements = []
if sag.get('customer_id'):
cid = sag.get('customer_id')
logger.info(f"🔎 Looking up fixed-price agreements for Sag {sag_id}, Customer ID: {cid}")
fpa_query = """
SELECT
a.id,
a.agreement_number,
a.monthly_hours,
COALESCE(bp.remaining_hours, a.monthly_hours) as remaining_hours_this_month
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON (
a.id = bp.agreement_id
AND bp.period_start <= CURRENT_DATE
AND bp.period_end >= CURRENT_DATE
)
WHERE a.customer_id = %s
AND a.status = 'active'
AND (a.end_date IS NULL OR a.end_date >= CURRENT_DATE)
ORDER BY a.created_at DESC
"""
fixed_price_agreements = execute_query(fpa_query, (cid,))
logger.info(f"📋 Found {len(fixed_price_agreements)} fixed-price agreements for customer {cid}")
# Fetch Nextcloud Instance for this customer
nextcloud_instance = None
if customer:
nc_query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL"
nc_result = execute_query(nc_query, (customer['id'],))
if nc_result:
nextcloud_instance = nc_result[0]
# Fetch linked contacts
contacts_query = """
SELECT
sk.*,
c.first_name || ' ' || c.last_name as contact_name,
c.email as contact_email,
c.phone,
c.mobile,
c.title,
company.customer_name
FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL (
SELECT cu.name AS customer_name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC, cu.name
LIMIT 1
) company ON TRUE
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
"""
contacts = execute_query(contacts_query, (sag_id,))
# Fetch linked customers
customers_query = """
SELECT sk.*, c.name as customer_name, c.email as customer_email
FROM sag_kunder sk
JOIN customers c ON sk.customer_id = c.id
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
"""
customers = execute_query(customers_query, (sag_id,))
# Fetch comments
comments_query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
comments = execute_query(comments_query, (sag_id,))
# Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None
# Fetch Time Entries
time_query = "SELECT * FROM tmodule_times WHERE sag_id = %s ORDER BY worked_date DESC"
time_entries = execute_query(time_query, (sag_id,))
# Fetch linked telephony call history
call_history_query = """
SELECT
t.id,
t.callid,
t.direction,
t.ekstern_nummer,
t.started_at,
t.ended_at,
t.duration_sec,
u.username,
u.full_name,
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
LEFT JOIN contacts c ON c.id = t.kontakt_id
WHERE t.sag_id = %s
ORDER BY t.started_at DESC
LIMIT 200
"""
call_history = execute_query(call_history_query, (sag_id,))
# Check for nextcloud integration (case-insensitive, insensitive to whitespace)
logger.info(f"Checking tags for Nextcloud on case {sag_id}: {tags}")
is_nextcloud = any(t['tag_navn'] and t['tag_navn'].strip().lower() == 'nextcloud' for t in tags)
logger.info(f"is_nextcloud result: {is_nextcloud}")
related_case_options = []
try:
related_ids = set()
for rel in relationer or []:
related_ids.add(rel["kilde_sag_id"])
related_ids.add(rel["målsag_id"])
related_ids.discard(sag_id)
if related_ids:
placeholders = ",".join(["%s"] * len(related_ids))
related_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders}) AND deleted_at IS NULL"
related_case_options = execute_query(related_query, tuple(related_ids))
except Exception as e:
logger.error("❌ Error building related case options: %s", e)
related_case_options = []
pipeline_stages = []
try:
pipeline_stages = execute_query(
"SELECT id, name, color, sort_order FROM pipeline_stages ORDER BY sort_order ASC, id ASC",
(),
)
except Exception as e:
logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = []
status_options = _fetch_case_status_options()
current_status = str(sag.get("status") or "").strip()
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
return templates.TemplateResponse("modules/sag/templates/detail_v3.html", {
"request": request,
"case": sag,
"customer": customer,
"hovedkontakt": hovedkontakt,
"contacts": contacts,
"customers": customers,
"prepaid_cards": prepaid_cards,
"fixed_price_agreements": fixed_price_agreements,
"tags": tags,
"relationer": relationer,
"relation_tree": relation_tree,
"comments": comments,
"solution": solution,
"time_entries": time_entries,
"call_history": call_history,
"is_nextcloud": is_nextcloud,
"nextcloud_instance": nextcloud_instance,
"related_case_options": related_case_options,
"pipeline_stages": pipeline_stages,
"status_options": status_options,
"is_deadline_overdue": is_deadline_overdue,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
})
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error displaying case details: %s", e)
raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/edit", response_class=HTMLResponse)
async def sag_rediger(request: Request, sag_id: int):
"""Display edit case form."""

View File

@ -13963,7 +13963,7 @@
async function loadSubscriptionForCase() {
try {
const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}`);
const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}?allow_missing=true`);
if (res.status === 404) {
showSubscriptionCreateForm();
setModuleContentState('subscription', false);
@ -13972,7 +13972,15 @@
if (!res.ok) {
throw new Error('Kunne ikke hente abonnement');
}
const subscription = await res.json();
const payload = await res.json();
const subscription = payload && Object.prototype.hasOwnProperty.call(payload, 'subscription')
? payload.subscription
: payload;
if (!subscription || !subscription.id) {
showSubscriptionCreateForm();
setModuleContentState('subscription', false);
return;
}
renderSubscription(subscription);
setModuleContentState('subscription', true);
} catch (e) {

File diff suppressed because it is too large Load Diff

View File

@ -208,7 +208,7 @@ def _auto_map_customer(account_id: Optional[str], customer_name: Optional[str],
@router.get("/sag-subscriptions/by-sag/{sag_id}", response_model=Dict[str, Any])
async def get_subscription_by_sag(sag_id: int):
async def get_subscription_by_sag(sag_id: int, allow_missing: bool = Query(False)):
"""Get latest subscription for a case."""
try:
query = """
@ -236,6 +236,8 @@ async def get_subscription_by_sag(sag_id: int):
"""
subscription = execute_query_single(query, (sag_id,))
if not subscription:
if allow_missing:
return {"subscription": None, "line_items": []}
raise HTTPException(status_code=404, detail="Subscription not found")
items = execute_query(
"""