2026-02-17 08:29:05 +01:00
|
|
|
import logging
|
2026-03-25 13:46:03 +01:00
|
|
|
from datetime import datetime, timedelta
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Request, Form
|
2025-12-06 02:22:01 +01:00
|
|
|
from fastapi.templating import Jinja2Templates
|
2026-02-17 08:29:05 +01:00
|
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
|
from app.core.database import execute_query, execute_query_single
|
2026-03-25 13:46:03 +01:00
|
|
|
from app.core.config import settings
|
|
|
|
|
from app.core.auth_service import AuthService
|
|
|
|
|
import jwt
|
2025-12-06 02:22:01 +01:00
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
templates = Jinja2Templates(directory="app")
|
2026-02-17 08:29:05 +01:00
|
|
|
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 [])
|
|
|
|
|
)
|
2025-12-06 02:22:01 +01:00
|
|
|
|
2026-03-25 13:46:03 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
@router.get("/", response_class=HTMLResponse)
|
2025-12-06 02:22:01 +01:00
|
|
|
async def dashboard(request: Request):
|
|
|
|
|
"""
|
|
|
|
|
Render the dashboard page
|
|
|
|
|
"""
|
2026-02-17 08:29:05 +01:00
|
|
|
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)
|
|
|
|
|
|
2026-01-10 21:09:29 +01:00
|
|
|
# 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')
|
|
|
|
|
"""
|
2026-01-11 19:23:21 +01:00
|
|
|
# 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
|
2026-01-10 21:09:29 +01:00
|
|
|
|
2026-03-20 00:24:58 +01:00
|
|
|
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
|
2026-01-11 19:23:21 +01:00
|
|
|
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)
|
2026-01-10 21:09:29 +01:00
|
|
|
|
|
|
|
|
return templates.TemplateResponse("dashboard/frontend/index.html", {
|
|
|
|
|
"request": request,
|
2026-01-11 19:23:21 +01:00
|
|
|
"unknown_worklog_count": unknown_count,
|
|
|
|
|
"bankruptcy_alerts": bankruptcy_alerts
|
2026-01-10 21:09:29 +01:00
|
|
|
})
|
2026-01-11 19:23:21 +01:00
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
|
|
|
|
|
@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,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-25 13:46:03 +01:00
|
|
|
|
|
|
|
|
@router.get("/dashboard/mission-control/", response_class=HTMLResponse)
|
|
|
|
|
async def mission_control_dashboard_trailing_slash(request: Request):
|
|
|
|
|
return await mission_control_dashboard(request)
|
|
|
|
|
|