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'
{error_text}
' if error_text else "" html = f""" Mission Control PIN

Mission Control

Indtast PIN-kode for at fortsætte.

{error_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)