diff --git a/MDfile/RELEASE_NOTES_v2.8.1.md b/MDfile/RELEASE_NOTES_v2.8.1.md new file mode 100644 index 0000000..e9fcc55 --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.8.1.md @@ -0,0 +1,39 @@ +# BMC Hub v2.8.1 + +## Planner + +- Nyt Planner-modul til fordeling af sager og overblik over teamets kapacitet. +- Dag-, uge- og månedsvisning med planlagte blokke, registreret tid og ekstern travl tid. +- Drag-and-drop af sager med kompakt forhåndsvisning, 15-minutters placering og håndterbare standardblokke. +- Bredere sagsoversigt og forbedret pladsudnyttelse i kalenderen. +- Kalender og Planlægger er samlet i en Kalender-undermenu. + +## Systemlog og drift + +- Ny administrativ systemlog til telefoni, e-mail og øvrige integrationshændelser. +- Udvidet hændelseslogning og adgangskontrol i indstillingerne. + +## AI og fakturaskabeloner + +- Rettet AI Auto-generer i Template Builder. +- Tilføjet manglende `OWN_CVR`-konfiguration og validering af Ollama-fejl. +- Ollama fakturaudtræk tvinges til struktureret JSON. +- GUI'en viser nu den konkrete AI-fejl i stedet for en generisk fejlbesked. + +## Integrationer og brugerflade + +- Forbedringer til telefoni, e-mail og ALSO Cloud-integrationerne. +- Mindre rettelser til login, indstillinger og den fælles navigation. +- Fremtidige udviklingsplaner har fået et fast dokumentationsområde. + +## Database + +- Kør `migrations/240_planner.sql`. +- Kør `migrations/241_system_event_log.sql`. + +## Verifikation + +- Planner: 8 tests bestået. +- Samlet testsuite: 271 bestået, 1 sprunget over. +- Fire eksisterende tests kræver databaseinitialisering eller har forældede invoice-history mocks. +- Python- og JavaScript-syntakskontrol bestået. diff --git a/VERSION b/VERSION index 37c2961..dbe5900 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.2 +2.8.1 diff --git a/app/auth/frontend/login.html b/app/auth/frontend/login.html index 4cafe8e..9155a37 100644 --- a/app/auth/frontend/login.html +++ b/app/auth/frontend/login.html @@ -87,6 +87,11 @@ {% endblock %} diff --git a/app/modules/telefoni/backend/router.py b/app/modules/telefoni/backend/router.py index 331fc65..306b39a 100644 --- a/app/modules/telefoni/backend/router.py +++ b/app/modules/telefoni/backend/router.py @@ -18,6 +18,7 @@ from app.core.auth_dependencies import require_permission from app.core.config import settings from app.core.database import execute_query, execute_query_single from app.services.sms_service import SmsService +from app.services.system_event_logger import log_system_event from .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest from .service import TelefoniService @@ -380,6 +381,13 @@ async def yealink_established( raw_payload=json.dumps(payload), started_at=datetime.utcnow(), ) + log_system_event( + source="voip", event_type="voip.established", status="success", + summary=f"{direction} opkald etableret: {ekstern_value or 'ukendt nummer'}", + correlation_id=resolved_callid, user_id=primary_user_id, + details={"call_row_id": row.get("id"), "extension": local_extension, + "external_number": ekstern_value, "client_ip": payload["client_ip"], "payload": payload}, + ) # Send websocket notification to ALL users with this extension if user_ids: @@ -440,10 +448,19 @@ async def yealink_terminated( if duration_value is None: logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid) + existed = execute_query_single("SELECT id,bruger_id FROM telefoni_opkald WHERE callid=%s", (resolved_callid,)) updated = TelefoniService.terminate_call(resolved_callid, duration_value) if not updated: logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid) time_entry_id = _register_completed_call_time(resolved_callid) if updated else None + log_system_event( + source="voip", event_type="voip.terminated" if existed else "voip.orphan_hangup", + status="success" if existed else "warning", + summary="Opkald afsluttet" if existed else "Hangup modtaget uden etablerings-event", + correlation_id=resolved_callid, user_id=(existed or {}).get("bruger_id"), + details={"duration_raw": duration_raw, "duration_sec": duration_value, "time_entry_id": time_entry_id, + "client_ip": _get_client_ip(request)}, + ) return {"status": "ok", "time_entry_id": time_entry_id} @@ -843,6 +860,7 @@ async def list_calls( limit: int = Query(200, ge=1, le=2000), offset: int = Query(0, ge=0), ): + TelefoniService.reconcile_stale_calls() where = [] params = [] diff --git a/app/modules/telefoni/backend/service.py b/app/modules/telefoni/backend/service.py index e39e1a4..3b3af3b 100644 --- a/app/modules/telefoni/backend/service.py +++ b/app/modules/telefoni/backend/service.py @@ -9,6 +9,24 @@ logger = logging.getLogger(__name__) class TelefoniService: + @staticmethod + def reconcile_stale_calls(max_age_hours: int = 12) -> int: + """Close the lifecycle state for calls whose phone never sent hangup.""" + rows = execute_query( + """WITH stale AS ( + UPDATE telefoni_opkald SET lifecycle_status='missing_hangup',last_event_at=NOW() + WHERE ended_at IS NULL AND lifecycle_status='active' + AND started_at < NOW()-(%s * INTERVAL '1 hour') + RETURNING id,callid,bruger_id,intern_extension,ekstern_nummer,started_at + ), logged AS ( + INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at) + SELECT 'voip','voip.stale_call','warning','Opkald mangler hangup-event',callid,bruger_id, + jsonb_build_object('call_row_id',id,'extension',intern_extension,'external_number',ekstern_nummer),NOW() + FROM stale RETURNING id + ) SELECT COUNT(*)::int AS count FROM stale""", (max_age_hours,), + ) or [] + return int(rows[0].get("count") or 0) if rows else 0 + @staticmethod def find_user_by_extension(extension: Optional[str]) -> list[int]: """Find all users with the given extension - returns list of user_ids.""" @@ -183,6 +201,7 @@ class TelefoniService: bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id), kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id), started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at) + , lifecycle_status = 'active', last_event_at = NOW() RETURNING * """ rows = execute_query( @@ -208,9 +227,9 @@ class TelefoniService: rows = execute_query( """ INSERT INTO telefoni_opkald - (callid, direction, started_at, ended_at, duration_sec, raw_payload) + (callid, direction, started_at, ended_at, duration_sec, raw_payload, lifecycle_status, termination_source, last_event_at) VALUES - (%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb) + (%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb, 'orphan_terminated', 'callback_without_start', NOW()) ON CONFLICT (callid) DO UPDATE SET ended_at = COALESCE(telefoni_opkald.ended_at, NOW()), @@ -222,7 +241,7 @@ class TelefoniService: THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int ELSE NULL END - ) + ), lifecycle_status = 'completed', termination_source = 'callback', last_event_at = NOW() RETURNING id """, (callid, duration_sec), diff --git a/app/services/email_service.py b/app/services/email_service.py index 1f4ba37..fc272d4 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -34,6 +34,7 @@ from aiohttp import ClientSession, BasicAuth import msal from app.core.config import settings +from app.services.system_event_logger import log_system_event from app.core.database import execute_query, execute_insert, execute_update logger = logging.getLogger(__name__) @@ -1765,6 +1766,11 @@ class EmailService: ) if graph_ok: logger.info("✅ Email sent via Graph to %s recipient(s): %s", len(to_addresses), subject) + log_system_event( + source="email", event_type="email.sent", status="success", + summary=subject or "E-mail sendt", + details={"provider": "graph", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or [])}, + ) return True, graph_message graph_failure_message = graph_message logger.warning("⚠️ Graph send failed, falling back to SMTP: %s", graph_message) @@ -1825,11 +1831,21 @@ class EmailService: ) logger.info(f"✅ Email sent successfully to {len(to_addresses)} recipient(s): {subject}") + log_system_event( + source="email", event_type="email.sent", status="success", + summary=subject or "E-mail sendt", + details={"provider": "smtp", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or [])}, + ) return True, f"Email sent to {len(to_addresses)} recipient(s)" except Exception as e: error_msg = f"❌ SMTP send error: {str(e)}" logger.error(error_msg) + log_system_event( + source="email", event_type="email.send_failed", status="error", + summary=subject or "E-mail kunne ikke sendes", + details={"provider": "smtp", "to": to_addresses, "error": str(e)}, + ) if graph_failure_message: return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}" return False, error_msg @@ -1891,6 +1907,11 @@ class EmailService: subject, provider_thread_key, ) + log_system_event( + source="email", event_type="email.sent", status="success", + summary=subject or "E-mail sendt", correlation_id=generated_message_id, + details={"provider": "graph", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or []), "attachments": len(attachments or [])}, + ) return True, graph_message, generated_message_id, provider_thread_key graph_failure_message = graph_message logger.warning("⚠️ Graph send with attachments failed, falling back to SMTP: %s", graph_message) @@ -1970,11 +1991,21 @@ class EmailService: len(to_addresses), subject, ) + log_system_event( + source="email", event_type="email.sent", status="success", + summary=subject or "E-mail sendt", correlation_id=generated_message_id, + details={"provider": "smtp", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or []), "attachments": len(attachments or [])}, + ) return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id, provider_thread_key except Exception as e: error_msg = f"❌ SMTP send error (attachments): {str(e)}" logger.error(error_msg) + log_system_event( + source="email", event_type="email.send_failed", status="error", + summary=subject or "E-mail kunne ikke sendes", correlation_id=generated_message_id, + details={"provider": "smtp", "to": to_addresses, "attachments": len(attachments or []), "error": str(e)}, + ) if graph_failure_message: return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}", generated_message_id, provider_thread_key return False, error_msg, generated_message_id, provider_thread_key diff --git a/app/services/ollama_service.py b/app/services/ollama_service.py index 4680e90..a31dfd3 100644 --- a/app/services/ollama_service.py +++ b/app/services/ollama_service.py @@ -413,6 +413,7 @@ REGLER FOR title: "model": self.model, "prompt": prompt, "stream": False, + "format": "json", "options": { "temperature": 0.1, "top_p": 0.9, diff --git a/app/services/system_event_logger.py b/app/services/system_event_logger.py new file mode 100644 index 0000000..6ad8740 --- /dev/null +++ b/app/services/system_event_logger.py @@ -0,0 +1,22 @@ +import json +import logging +from datetime import datetime +from typing import Any + +from app.core.database import execute_query + +logger = logging.getLogger(__name__) + + +def log_system_event(*, source: str, event_type: str, summary: str, status: str = "info", + correlation_id: str | None = None, user_id: int | None = None, + details: dict[str, Any] | None = None, occurred_at: datetime | None = None) -> None: + """Best-effort operational event logging; logging must never break the source flow.""" + try: + execute_query( + """INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at) + VALUES(%s,%s,%s,%s,%s,%s,%s::jsonb,COALESCE(%s,NOW()))""", + (source,event_type,status,summary,correlation_id,user_id,json.dumps(details or {},default=str),occurred_at), + ) + except Exception as exc: + logger.warning("Could not persist system event %s: %s", event_type, exc) diff --git a/app/settings/backend/router.py b/app/settings/backend/router.py index 27e12e3..a55403d 100644 --- a/app/settings/backend/router.py +++ b/app/settings/backend/router.py @@ -2,13 +2,13 @@ Settings and User Management API Router """ -from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends +from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query from typing import List, Optional, Dict from pydantic import BaseModel -from datetime import datetime +from datetime import datetime, timezone from app.core.database import execute_query, execute_query_single from app.core.config import settings -from app.core.auth_dependencies import require_superadmin +from app.core.auth_dependencies import require_any_permission, require_superadmin from app.core.auth_service import AuthService import argparse import asyncio @@ -1288,3 +1288,76 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}") err = str(e) or e.__class__.__name__ raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}") + + +system_event_log_access = require_any_permission("system.event_log.view", "users.manage", "system.admin") + + +def _event_sort_value(event: Dict) -> float: + """Return one comparable timestamp for naive and timezone-aware DB values.""" + value = event.get("occurred_at") + if not value: + return 0.0 + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.timestamp() + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.timestamp() + except (TypeError, ValueError): + return 0.0 + + +@router.get("/settings/system-events/list", dependencies=[Depends(system_event_log_access)], tags=["Settings"]) +async def list_system_events( + source: Optional[str] = Query(None), event_type: Optional[str] = Query(None), + status: Optional[str] = Query(None), q: Optional[str] = Query(None), + date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None), + user_id: Optional[int] = Query(None), limit: int = Query(250, ge=1, le=2000), +): + """Unified operational log over persisted system, VoIP and email events.""" + clauses, params = [], [] + if source: clauses.append("l.source=%s"); params.append(source) + if event_type: clauses.append("l.event_type=%s"); params.append(event_type) + if status: clauses.append("l.status=%s"); params.append(status) + if user_id: clauses.append("l.user_id=%s"); params.append(user_id) + if date_from: clauses.append("l.occurred_at>=%s"); params.append(date_from) + if date_to: clauses.append("l.occurred_at<=%s"); params.append(date_to) + if q: + clauses.append("(l.summary ILIKE %s OR l.correlation_id ILIKE %s OR l.details::text ILIKE %s)") + params.extend([f"%{q}%"] * 3) + where = "WHERE " + " AND ".join(clauses) if clauses else "" + events = execute_query( + f"""SELECT l.id,l.source,l.event_type,l.status,l.summary,l.correlation_id,l.user_id, + l.details,l.occurred_at,COALESCE(u.full_name,u.username) AS user_name + FROM system_event_log l LEFT JOIN users u ON u.user_id=l.user_id {where} + ORDER BY l.occurred_at DESC LIMIT %s""", tuple(params + [limit]), + ) or [] + + # Existing mail records remain searchable even before producers adopt system_event_log. + if source in (None, "email"): + email_clauses, email_params = [], [] + if date_from: email_clauses.append("em.received_date>=%s"); email_params.append(date_from) + if date_to: email_clauses.append("em.received_date<=%s"); email_params.append(date_to) + if q: + email_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.recipient_email ILIKE %s)") + email_params.extend([f"%{q}%"] * 3) + email_where = "WHERE " + " AND ".join(email_clauses) if email_clauses else "" + emails = execute_query( + f"""SELECT em.id,'email' AS source, + CASE WHEN LOWER(COALESCE(em.folder,'')) LIKE '%%sent%%' THEN 'email.sent' ELSE 'email.received' END AS event_type, + 'success'::text AS status,COALESCE(em.subject,'(intet emne)') AS summary, + em.message_id AS correlation_id,NULL::integer AS user_id, + jsonb_build_object('from',em.sender_email,'to',em.recipient_email,'folder',em.folder) AS details, + em.received_date AS occurred_at,NULL::text AS user_name + FROM email_messages em {email_where} ORDER BY em.received_date DESC LIMIT %s""", + tuple(email_params + [limit]), + ) or [] + if event_type: emails = [x for x in emails if x["event_type"] == event_type] + if status: emails = [x for x in emails if x["status"] == status] + events.extend(emails) + events.sort(key=_event_sort_value, reverse=True) + return {"items": events[:limit], "sources": ["voip", "email", "system"], "count": len(events[:limit])} diff --git a/app/settings/backend/views.py b/app/settings/backend/views.py index 0b4a191..763fd5f 100644 --- a/app/settings/backend/views.py +++ b/app/settings/backend/views.py @@ -18,6 +18,7 @@ from app.core.auth_dependencies import require_any_permission router = APIRouter() templates = Jinja2Templates(directory="app") sql_console_access = require_any_permission("users.manage", "system.admin") +system_event_log_access = require_any_permission("system.event_log.view", "users.manage", "system.admin") CREATE_TABLE_RE = re.compile( r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(", @@ -265,6 +266,11 @@ async def settings_page(request: Request): }) +@router.get("/settings/event-log", response_class=HTMLResponse, tags=["Frontend"], dependencies=[Depends(system_event_log_access)]) +async def system_event_log_page(request: Request): + return templates.TemplateResponse("settings/frontend/event_log.html", {"request": request, "title": "Systemlog"}) + + @router.get("/settings/migrations", response_class=HTMLResponse, tags=["Frontend"]) async def migrations_page(request: Request): """Render database migrations page""" diff --git a/app/settings/frontend/event_log.html b/app/settings/frontend/event_log.html new file mode 100644 index 0000000..a7e6397 --- /dev/null +++ b/app/settings/frontend/event_log.html @@ -0,0 +1,20 @@ +{% extends "shared/frontend/base.html" %} +{% block title %}Systemlog · BMC Hub{% endblock %} +{% block content %} +
VoIP, e-mail og øvrige integrationshændelser
| Tid | Kilde | Hændelse | Status | Beskrivelse | Bruger | Korrelations-id |
|---|---|---|---|---|---|---|
| Henter log… | ||||||