import logging import json from datetime import date, datetime from typing import Optional from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pathlib import Path from app.core.database import execute_query logger = logging.getLogger(__name__) router = APIRouter() def _is_deadline_overdue(deadline_value) -> bool: if not deadline_value: return False if isinstance(deadline_value, datetime): return deadline_value.date() < date.today() if isinstance(deadline_value, date): return deadline_value < date.today() return False # Setup template directory templates = Jinja2Templates(directory="app") def _fetch_assignment_users(): return execute_query( """ SELECT user_id, COALESCE(full_name, username) AS display_name FROM users ORDER BY display_name """, () ) or [] def _fetch_assignment_groups(): return execute_query( """ SELECT id, name FROM groups ORDER BY name """, () ) or [] def _coerce_optional_int(value: Optional[str]) -> Optional[int]: """Convert empty strings and None to None, otherwise parse as int.""" if value is None or value == "": return None try: return int(value) except (TypeError, ValueError): return None def _fetch_case_status_options() -> list[str]: default_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"] values = [] seen = set() def _add(value: Optional[str]) -> None: candidate = str(value or "").strip() if not candidate: return key = candidate.lower() if key in seen: return seen.add(key) values.append(candidate) setting_row = execute_query( "SELECT value FROM settings WHERE key = %s", ("case_statuses",) ) if setting_row and setting_row[0].get("value"): try: parsed = json.loads(setting_row[0].get("value") or "[]") for item in parsed if isinstance(parsed, list) else []: value = "" if isinstance(item, str): value = item.strip() elif isinstance(item, dict): value = str(item.get("value") or "").strip() _add(value) except Exception: pass statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or [] for row in statuses: _add(row.get("status")) for default in default_statuses: _add(default) return values @router.get("/sag", response_class=HTMLResponse) async def sager_liste( request: Request, status: str = Query(None), tag: str = Query(None), customer_id: str = Query(None), ansvarlig_bruger_id: str = Query(None), assigned_group_id: str = Query(None), include_deferred: bool = Query(False), ): """Display list of all cases.""" try: # Coerce string params to optional ints customer_id_int = _coerce_optional_int(customer_id) ansvarlig_bruger_id_int = _coerce_optional_int(ansvarlig_bruger_id) assigned_group_id_int = _coerce_optional_int(assigned_group_id) query = """ SELECT s.*, c.name as customer_name, CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn, COALESCE(u.full_name, u.username) AS ansvarlig_navn, g.name AS assigned_group_name, nt.title AS next_todo_title, nt.due_date AS next_todo_due_date FROM sag_sager s LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN groups g ON g.id = s.assigned_group_id LEFT JOIN LATERAL ( SELECT cc.contact_id FROM contact_companies cc WHERE cc.customer_id = c.id ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC LIMIT 1 ) cc_first ON true LEFT JOIN contacts cont ON cc_first.contact_id = cont.id LEFT JOIN LATERAL ( SELECT t.title, t.due_date FROM sag_todo_steps t WHERE t.sag_id = s.id AND t.deleted_at IS NULL AND t.is_done = FALSE ORDER BY CASE WHEN t.is_next THEN 0 WHEN t.due_date IS NOT NULL THEN 1 ELSE 2 END, t.due_date ASC NULLS LAST, t.created_at ASC LIMIT 1 ) nt ON true LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id WHERE s.deleted_at IS NULL """ params = [] if not include_deferred: query += " AND (" query += "s.deferred_until IS NULL" query += " OR s.deferred_until <= NOW()" query += " OR (s.deferred_until_case_id IS NOT NULL AND s.deferred_until_status IS NOT NULL AND ds.status = s.deferred_until_status)" query += ")" if status: query += " AND s.status = %s" params.append(status) if customer_id_int: query += " AND s.customer_id = %s" params.append(customer_id_int) if ansvarlig_bruger_id_int: query += " AND s.ansvarlig_bruger_id = %s" params.append(ansvarlig_bruger_id_int) if assigned_group_id_int: query += " AND s.assigned_group_id = %s" params.append(assigned_group_id_int) query += " ORDER BY s.created_at DESC" sager = execute_query(query, tuple(params)) # Fetch relations for all cases relations_query = """ SELECT sr.kilde_sag_id, sr.målsag_id, sr.relationstype, sr.id as relation_id FROM sag_relationer sr WHERE sr.deleted_at IS NULL """ all_relations = execute_query(relations_query, ()) child_ids = set() # Build relations map: {sag_id: [list of related sag_ids]} relations_map = {} for rel in all_relations or []: if rel.get('målsag_id') is not None: child_ids.add(rel['målsag_id']) # Add forward relation if rel['kilde_sag_id'] not in relations_map: relations_map[rel['kilde_sag_id']] = [] relations_map[rel['kilde_sag_id']].append({ 'target_id': rel['målsag_id'], 'type': rel['relationstype'], 'direction': 'forward' }) # Add backward relation if rel['målsag_id'] not in relations_map: relations_map[rel['målsag_id']] = [] relations_map[rel['målsag_id']].append({ 'target_id': rel['kilde_sag_id'], 'type': rel['relationstype'], 'direction': 'backward' }) # Filter by tag if provided if tag and sager: sag_ids = [s['id'] for s in sager] tag_query = "SELECT sag_id FROM sag_tags WHERE tag_navn = %s AND deleted_at IS NULL" tagged = execute_query(tag_query, (tag,)) tagged_ids = set(t['sag_id'] for t in tagged) sager = [s for s in sager if s['id'] in tagged_ids] # Fetch all distinct statuses and tags for filters status_options = _fetch_case_status_options() current_status = str(status or "").strip() if current_status and current_status.lower() not in {s.lower() for s in status_options}: status_options.append(current_status) all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ()) toggle_include_deferred_url = str( request.url.include_query_params(include_deferred="0" if include_deferred else "1") ) return templates.TemplateResponse("modules/sag/templates/index.html", { "request": request, "sager": sager, "relations_map": relations_map, "child_ids": list(child_ids), "statuses": status_options, "all_tags": [t['tag_navn'] for t in all_tags], "current_status": status, "current_tag": tag, "include_deferred": include_deferred, "toggle_include_deferred_url": toggle_include_deferred_url, "assignment_users": _fetch_assignment_users(), "assignment_groups": _fetch_assignment_groups(), "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_assigned_group_id": assigned_group_id_int, }) except Exception as e: logger.error("❌ Error displaying case list: %s", e) raise HTTPException(status_code=500, detail="Failed to load case list") @router.get("/sag/new", response_class=HTMLResponse) async def opret_sag_side(request: Request): """Show create case form.""" return templates.TemplateResponse("modules/sag/templates/create.html", { "request": request, "assignment_users": _fetch_assignment_users(), "assignment_groups": _fetch_assignment_groups(), }) @router.get("/sag/varekob-salg", response_class=HTMLResponse) async def sag_varekob_salg(request: Request): """Display orders overview for all purchases and sales.""" return templates.TemplateResponse("modules/sag/templates/varekob_salg.html", { "request": request, }) @router.get("/sag/{sag_id}", response_class=HTMLResponse) async def sag_detaljer(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 = [] except Exception as e: logger.error(f"Error building relation tree: {e}") relation_tree = [] # 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 ASC" 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.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.""" try: sag_query = "SELECT * FROM sag_sager WHERE id = %s AND 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") return templates.TemplateResponse("modules/sag/templates/edit.html", { "request": request, "case": sag_result[0], "assignment_users": _fetch_assignment_users(), "assignment_groups": _fetch_assignment_groups(), }) except HTTPException: raise except Exception as e: logger.error("❌ Error loading edit case page: %s", e) raise HTTPException(status_code=500, detail="Failed to load edit case page")