bmc_hub/app/dashboard/backend/views.py

493 lines
17 KiB
Python
Raw Normal View History

import logging
from datetime import datetime, timedelta
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
from app.core.config import settings
from app.core.auth_service import AuthService
import jwt
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 [])
)
def _get_mission_access_pin() -> str:
row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("mission_access_pin",))
db_pin = str((row or {}).get("value") or "").strip()
env_pin = str(getattr(settings, "MISSION_ACCESS_PIN", "") or "").strip()
return db_pin or env_pin
def _has_valid_mission_pin_token(request: Request) -> bool:
token = request.cookies.get("mission_pin_token")
payload = AuthService.verify_token(token) if token else None
return bool(payload and payload.get("scope") == "mission_pin")
def _create_mission_pin_token() -> str:
payload = {
"sub": "0",
"username": "mission-kiosk",
"shadow_admin": True,
"scope": "mission_pin",
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(hours=12),
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
def _sanitize_mission_next(value: str) -> str:
if not value:
return "/dashboard/mission-control"
candidate = value.strip()
if candidate == "/dashboard/mission-control":
return candidate
if candidate.startswith("/api/v1/mission/"):
return candidate
return "/dashboard/mission-control"
def _render_mission_pin_page(error_text: str = "", next_path: str = "/dashboard/mission-control") -> HTMLResponse:
safe_next = _sanitize_mission_next(next_path)
error_html = f'<div style="margin:0.75rem 0;color:#ffb4b4;">{error_text}</div>' if error_text else ""
html = f"""
<!DOCTYPE html>
<html lang=\"da\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>Mission Control PIN</title>
<style>
body {{ margin: 0; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; background: #0b1320; color: #e9f1ff; display: grid; place-items: center; min-height: 100vh; }}
.card {{ width: min(92vw, 420px); padding: 1.2rem; border: 1px solid #2c3c58; border-radius: 14px; background: #121d2f; }}
.title {{ font-size: 1.2rem; font-weight: 700; margin: 0 0 0.5rem 0; }}
.hint {{ color: #9fb3d1; font-size: 0.9rem; margin: 0 0 0.9rem 0; }}
input {{ width: 100%; box-sizing: border-box; border: 1px solid #2c3c58; border-radius: 10px; background: #0f1a2b; color: #e9f1ff; padding: 0.7rem; font-size: 1rem; }}
button {{ width: 100%; margin-top: 0.75rem; border: 1px solid #3b82f6; border-radius: 10px; background: #1f5bb8; color: #fff; font-weight: 600; padding: 0.65rem; cursor: pointer; }}
</style>
</head>
<body>
<div class=\"card\">
<p class=\"title\">Mission Control</p>
<p class=\"hint\">Indtast PIN-kode for at fortsætte.</p>
{error_html}
<form method=\"post\" action=\"/mission/pin/verify\">
<input type=\"hidden\" name=\"next\" value=\"{safe_next}\" />
<input type=\"password\" name=\"pin\" inputmode=\"numeric\" autocomplete=\"one-time-code\" placeholder=\"PIN-kode\" required />
<button type=\"submit\">Åbn Mission Control</button>
</form>
</div>
</body>
</html>
"""
return HTMLResponse(content=html)
@router.get("/mission/pin", response_class=HTMLResponse)
async def mission_pin_page(request: Request, next: str = "/dashboard/mission-control"):
if _has_valid_mission_pin_token(request):
return RedirectResponse(url=_sanitize_mission_next(next), status_code=302)
return _render_mission_pin_page(next_path=next)
@router.get("/mission/pin/", response_class=HTMLResponse)
async def mission_pin_page_trailing_slash(request: Request, next: str = "/dashboard/mission-control"):
return await mission_pin_page(request, next)
@router.post("/mission/pin/verify")
async def mission_pin_verify(pin: str = Form(...), next: str = Form("/dashboard/mission-control")):
configured_pin = _get_mission_access_pin()
if not configured_pin:
return _render_mission_pin_page("PIN er ikke konfigureret på serveren.", next)
if pin.strip() != configured_pin:
return _render_mission_pin_page("Forkert PIN-kode.", next)
token = _create_mission_pin_token()
redirect_target = _sanitize_mission_next(next)
response = RedirectResponse(url=redirect_target, status_code=302)
response.set_cookie(
key="mission_pin_token",
value=token,
httponly=True,
samesite="Lax",
max_age=60 * 60 * 12,
)
return response
@router.post("/mission/pin/logout")
async def mission_pin_logout():
response = RedirectResponse(url="/mission/pin", status_code=302)
response.delete_cookie("mission_pin_token")
return response
@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,
}
)
@router.get("/dashboard/mission-control/", response_class=HTMLResponse)
async def mission_control_dashboard_trailing_slash(request: Request):
return await mission_control_dashboard(request)