bmc_hub/app/dashboard/backend/views.py

371 lines
12 KiB
Python
Raw Normal View History

import logging
from fastapi import APIRouter, Request, Form
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, RedirectResponse
from app.core.database import execute_query, execute_query_single
router = APIRouter()
templates = Jinja2Templates(directory="app")
logger = logging.getLogger(__name__)
_DISALLOWED_DASHBOARD_PATHS = {
"/dashboard/default",
"/dashboard/default/clear",
}
def _sanitize_dashboard_path(value: str) -> str:
if not value:
return ""
candidate = value.strip()
if not candidate.startswith("/"):
return ""
if candidate.startswith("/api"):
return ""
if candidate.startswith("//"):
return ""
if candidate in _DISALLOWED_DASHBOARD_PATHS:
return ""
return candidate
def _get_user_default_dashboard(user_id: int) -> str:
try:
row = execute_query_single(
"""
SELECT default_dashboard_path
FROM user_dashboard_preferences
WHERE user_id = %s
""",
(user_id,)
)
return _sanitize_dashboard_path((row or {}).get("default_dashboard_path", ""))
except Exception as exc:
if "user_dashboard_preferences" in str(exc):
logger.warning("⚠️ user_dashboard_preferences table not found; using fallback default dashboard")
return ""
raise
def _get_user_group_names(user_id: int):
rows = execute_query(
"""
SELECT LOWER(g.name) AS name
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = %s
""",
(user_id,)
)
return [r["name"] for r in (rows or []) if r.get("name")]
def _is_technician_group(group_names) -> bool:
return any(
"technician" in group or "teknik" in group
for group in (group_names or [])
)
def _is_sales_group(group_names) -> bool:
return any(
"sales" in group or "salg" in group
for group in (group_names or [])
)
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""
Render the dashboard page
"""
user_id = getattr(request.state, "user_id", None)
preferred_dashboard = ""
if user_id:
preferred_dashboard = _get_user_default_dashboard(int(user_id))
if not preferred_dashboard:
preferred_dashboard = _sanitize_dashboard_path(request.cookies.get("bmc_default_dashboard", ""))
if preferred_dashboard and preferred_dashboard != "/":
return RedirectResponse(url=preferred_dashboard, status_code=302)
if user_id:
group_names = _get_user_group_names(int(user_id))
if _is_technician_group(group_names):
return RedirectResponse(
url=f"/ticket/dashboard/technician/v1?technician_user_id={int(user_id)}",
status_code=302
)
if _is_sales_group(group_names):
return RedirectResponse(url="/dashboard/sales", status_code=302)
# Fetch count of unknown billing worklogs
unknown_query = """
SELECT COUNT(*) as count
FROM tticket_worklog
WHERE billing_method = 'unknown'
AND status NOT IN ('billed', 'rejected')
"""
# Fetch active bankruptcy alerts
# Finds emails classified as 'bankruptcy' that are not processed
bankruptcy_query = """
SELECT e.id, e.subject, e.received_date,
v.name as vendor_name, v.id as vendor_id,
c.name as customer_name, c.id as customer_id
FROM email_messages e
LEFT JOIN vendors v ON e.supplier_id = v.id
LEFT JOIN customers c ON e.customer_id = c.id
WHERE e.classification = 'bankruptcy'
AND e.status NOT IN ('archived')
AND (e.customer_id IS NOT NULL OR e.supplier_id IS NOT NULL)
ORDER BY e.received_date DESC
"""
from app.core.database import execute_query
try:
result = execute_query_single(unknown_query)
unknown_count = result['count'] if result else 0
except Exception as exc:
if "tticket_worklog" in str(exc):
logger.warning("⚠️ tticket_worklog table not found; defaulting unknown worklog count to 0")
unknown_count = 0
else:
raise
try:
raw_alerts = execute_query(bankruptcy_query) or []
except Exception as exc:
if "email_messages" in str(exc):
logger.warning("⚠️ email_messages table not found; skipping bankruptcy alerts")
raw_alerts = []
else:
raise
bankruptcy_alerts = []
for alert in raw_alerts:
item = dict(alert)
# Determine display name
if item.get('customer_name'):
item['display_name'] = f"Kunde: {item['customer_name']}"
elif item.get('vendor_name'):
item['display_name'] = item['vendor_name']
elif 'statstidende' in item.get('subject', '').lower():
item['display_name'] = 'Statstidende'
else:
item['display_name'] = 'Ukendt Afsender'
bankruptcy_alerts.append(item)
return templates.TemplateResponse("dashboard/frontend/index.html", {
"request": request,
"unknown_worklog_count": unknown_count,
"bankruptcy_alerts": bankruptcy_alerts
})
@router.get("/dashboard/sales", response_class=HTMLResponse)
async def sales_dashboard(request: Request):
pipeline_stats_query = """
SELECT
COUNT(*) FILTER (WHERE s.status = 'åben') AS open_count,
COUNT(*) FILTER (WHERE s.status = 'lukket') AS closed_count,
COALESCE(SUM(COALESCE(s.pipeline_amount, 0)) FILTER (WHERE s.status = 'åben'), 0) AS open_value,
COALESCE(AVG(COALESCE(s.pipeline_probability, 0)) FILTER (WHERE s.status = 'åben'), 0) AS avg_probability
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND (
s.template_key = 'pipeline'
OR EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'case'
AND et.entity_id = s.id
AND LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
)
)
"""
recent_opportunities_query = """
SELECT
s.id,
s.titel,
s.status,
s.pipeline_amount,
s.pipeline_probability,
ps.name AS pipeline_stage,
s.deadline,
s.created_at,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
COALESCE(u.full_name, u.username, 'Ingen') AS owner_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
WHERE s.deleted_at IS NULL
AND (
s.template_key = 'pipeline'
OR EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'case'
AND et.entity_id = s.id
AND LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
)
)
ORDER BY s.created_at DESC
LIMIT 12
"""
due_soon_query = """
SELECT
s.id,
s.titel,
s.deadline,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
COALESCE(u.full_name, u.username, 'Ingen') AS owner_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL
AND s.deadline IS NOT NULL
AND s.deadline BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '14 days')
AND (
s.template_key = 'pipeline'
OR EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'case'
AND et.entity_id = s.id
AND LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
)
)
ORDER BY s.deadline ASC
LIMIT 8
"""
pipeline_stats = execute_query_single(pipeline_stats_query) or {}
recent_opportunities = execute_query(recent_opportunities_query) or []
due_soon = execute_query(due_soon_query) or []
return templates.TemplateResponse(
"dashboard/frontend/sales.html",
{
"request": request,
"pipeline_stats": pipeline_stats,
"recent_opportunities": recent_opportunities,
"due_soon": due_soon,
"default_dashboard": _get_user_default_dashboard(getattr(request.state, "user_id", 0) or 0)
or request.cookies.get("bmc_default_dashboard", "")
}
)
@router.post("/dashboard/default")
async def set_default_dashboard(
request: Request,
dashboard_path: str = Form(...),
redirect_to: str = Form(default="/")
):
safe_path = _sanitize_dashboard_path(dashboard_path)
safe_redirect = _sanitize_dashboard_path(redirect_to) or "/"
user_id = getattr(request.state, "user_id", None)
response = RedirectResponse(url=safe_redirect, status_code=303)
if safe_path:
if user_id:
try:
execute_query(
"""
INSERT INTO user_dashboard_preferences (user_id, default_dashboard_path, updated_at)
VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (user_id)
DO UPDATE SET
default_dashboard_path = EXCLUDED.default_dashboard_path,
updated_at = CURRENT_TIMESTAMP
""",
(int(user_id), safe_path)
)
except Exception as exc:
if "user_dashboard_preferences" in str(exc):
logger.warning("⚠️ Could not persist dashboard preference in DB (table missing); cookie fallback still active")
else:
raise
response.set_cookie(
key="bmc_default_dashboard",
value=safe_path,
httponly=True,
samesite="Lax"
)
return response
@router.get("/dashboard/default")
async def set_default_dashboard_get_fallback():
return RedirectResponse(url="/settings#system", status_code=303)
@router.post("/dashboard/default/clear")
async def clear_default_dashboard(
request: Request,
redirect_to: str = Form(default="/")
):
safe_redirect = _sanitize_dashboard_path(redirect_to) or "/"
user_id = getattr(request.state, "user_id", None)
if user_id:
try:
execute_query(
"DELETE FROM user_dashboard_preferences WHERE user_id = %s",
(int(user_id),)
)
except Exception as exc:
if "user_dashboard_preferences" in str(exc):
logger.warning("⚠️ Could not clear DB dashboard preference (table missing); cookie fallback still active")
else:
raise
response = RedirectResponse(url=safe_redirect, status_code=303)
response.delete_cookie("bmc_default_dashboard")
return response
@router.get("/dashboard/default/clear")
async def clear_default_dashboard_get_fallback():
return RedirectResponse(url="/settings#system", status_code=303)
@router.get("/dashboard/mission-control", response_class=HTMLResponse)
async def mission_control_dashboard(request: Request):
return templates.TemplateResponse(
"dashboard/frontend/mission_control.html",
{
"request": request,
}
)