bmc_hub/app/modules/sag/frontend/views.py
Christian 891180f3f0 Refactor opportunities and settings management
- Removed opportunity detail page route from views.py.
- Deleted opportunity_service.py as it is no longer needed.
- Updated router.py to seed new setting for case_type_module_defaults.
- Enhanced settings.html to include standard modules per case type with UI for selection.
- Implemented JavaScript functions to manage case type module defaults.
- Added RelationService for handling case relations with a tree structure.
- Created migration scripts (128 and 129) for new pipeline fields and descriptions.
- Added script to fix relation types in the database.
2026-02-15 11:12:58 +01:00

428 lines
17 KiB
Python

import logging
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()
# Setup template directory
templates = Jinja2Templates(directory="app")
@router.get("/sag", response_class=HTMLResponse)
async def sager_liste(
request: Request,
status: str = Query(None),
tag: str = Query(None),
customer_id: int = Query(None),
include_deferred: bool = Query(False),
):
"""Display list of all cases."""
try:
query = """
SELECT s.*,
c.name as customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.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 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:
query += " AND s.customer_id = %s"
params.append(customer_id)
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
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY 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": [s['status'] for s in statuses],
"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,
})
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})
@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 * 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")
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 = []
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
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": [s["status"] for s in statuses],
})
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],
})
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")