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, } )