- Created migration 146 to seed case type tags with various categories and keywords. - Created migration 147 to seed brand and type tags, including a comprehensive list of brands and case types. - Added migration 148 to introduce a new column `is_next` in `sag_todo_steps` for persistent next-task selection. - Implemented a new script `run_migrations.py` to facilitate running SQL migrations against the PostgreSQL database with options for dry runs and error handling.
371 lines
12 KiB
Python
371 lines
12 KiB
Python
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,
|
|
}
|
|
)
|
|
|