Release v2.2.67: mission touch UX, camera/webhook, env temperature feed

This commit is contained in:
Christian 2026-03-25 13:46:03 +01:00
parent daf2f29471
commit 43fd651723
16 changed files with 2842 additions and 311 deletions

View File

@ -1,9 +1,13 @@
import json import json
import logging import logging
import io
import time
from datetime import datetime from datetime import datetime
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
@ -32,6 +36,35 @@ class MissionUptimeWebhook(BaseModel):
payload: Dict[str, Any] = Field(default_factory=dict) payload: Dict[str, Any] = Field(default_factory=dict)
class MissionCameraConfigUpdate(BaseModel):
enabled: bool = False
camera_name: Optional[str] = None
feed_url: Optional[str] = None
spotlight_seconds: Optional[int] = 20
class MissionCameraMotionWebhook(BaseModel):
camera_name: Optional[str] = None
motion: Optional[bool] = True
event_type: Optional[str] = None
timestamp: Optional[datetime] = None
snapshot_url: Optional[str] = None
payload: Dict[str, Any] = Field(default_factory=dict)
class MissionAccessPinUpdate(BaseModel):
pin: str = Field(..., min_length=4, max_length=10)
class MissionTemperatureWebhook(BaseModel):
sensor_id: Optional[str] = None
sensor_name: Optional[str] = None
temperature: float
unit: Optional[str] = "°C"
timestamp: Optional[datetime] = None
payload: Dict[str, Any] = Field(default_factory=dict)
def _first_query_param(request: Request, *names: str) -> Optional[str]: def _first_query_param(request: Request, *names: str) -> Optional[str]:
for name in names: for name in names:
value = request.query_params.get(name) value = request.query_params.get(name)
@ -128,21 +161,265 @@ def _normalize_uptime_payload(payload: MissionUptimeWebhook) -> Dict[str, Any]:
} }
def _is_valid_feed_url(candidate: Optional[str]) -> bool:
if not candidate:
return False
try:
parsed = urlparse(candidate.strip())
except Exception:
return False
return parsed.scheme in {"http", "https", "rtsp"} and bool(parsed.netloc)
def _require_authenticated_user(request: Request) -> Dict[str, Any]:
token = None
auth_header = (request.headers.get("authorization") or "").strip()
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = (request.cookies.get("access_token") or "").strip()
payload = AuthService.verify_token(token) if token else None
if not payload or payload.get("scope") == "mission_pin":
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = payload.get("sub") or payload.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token")
return payload
def _is_valid_access_pin(pin: str) -> bool:
return pin.isdigit() and 4 <= len(pin) <= 10
def _iter_mjpeg_frames(feed_url: str, target_fps: float = 5.0):
"""Transcode camera frames to MJPEG for browser playback."""
try:
import av
except Exception as exc:
logger.error("❌ PyAV import failed for camera stream: %s", exc)
raise HTTPException(status_code=503, detail="PyAV ikke installeret på serveren")
options = {
"rtsp_transport": "tcp",
"fflags": "nobuffer",
"flags": "low_delay",
"stimeout": "5000000",
}
boundary = b"frame"
frame_interval = 1.0 / max(1.0, float(target_fps))
last_emit = 0.0
container = None
try:
container = av.open(feed_url, options=options)
video_stream = next((s for s in container.streams if s.type == "video"), None)
if video_stream is None:
raise HTTPException(status_code=400, detail="Feed indeholder ingen video stream")
for frame in container.decode(video=0):
now = time.monotonic()
if now - last_emit < frame_interval:
continue
last_emit = now
image = frame.to_image()
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=80)
jpeg = buffer.getvalue()
yield (
b"--" + boundary + b"\r\n"
+ b"Content-Type: image/jpeg\r\n"
+ f"Content-Length: {len(jpeg)}\r\n\r\n".encode("ascii")
+ jpeg
+ b"\r\n"
)
except HTTPException:
raise
except Exception as exc:
logger.error("❌ Camera MJPEG stream failed: %s", exc)
raise HTTPException(status_code=502, detail="Kunne ikke åbne kamera stream")
finally:
if container is not None:
try:
container.close()
except Exception:
pass
def _probe_camera_stream(feed_url: str) -> Dict[str, Any]:
"""Attempt opening and decoding one frame to provide actionable diagnostics."""
try:
import av
except Exception:
return {"ok": False, "detail": "PyAV ikke installeret på serveren"}
options = {
"rtsp_transport": "tcp",
"fflags": "nobuffer",
"flags": "low_delay",
"stimeout": "5000000",
}
container = None
try:
container = av.open(feed_url, options=options)
video_stream = next((s for s in container.streams if s.type == "video"), None)
if video_stream is None:
return {"ok": False, "detail": "Feed indeholder ingen video stream"}
frame_found = False
for _ in container.decode(video=0):
frame_found = True
break
if not frame_found:
return {"ok": False, "detail": "Ingen frames modtaget fra kamera"}
return {"ok": True, "detail": "Stream OK"}
except Exception as exc:
return {"ok": False, "detail": f"Kamera stream fejl: {exc}"}
finally:
if container is not None:
try:
container.close()
except Exception:
pass
@router.get("/mission/state") @router.get("/mission/state")
async def get_mission_state(): async def get_mission_state():
return MissionService.get_state() return MissionService.get_state()
@router.get("/mission/camera/mjpeg")
async def mission_camera_mjpeg_stream(fps: float = Query(5.0, ge=1.0, le=15.0)):
feed_url = (MissionService.get_setting_value("mission_camera_feed_url", "") or "").strip()
enabled = str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true"
if not enabled:
raise HTTPException(status_code=400, detail="Kamera feed er ikke aktiveret")
if not feed_url:
raise HTTPException(status_code=400, detail="Kamera feed URL mangler")
if not _is_valid_feed_url(feed_url):
raise HTTPException(status_code=400, detail="Ugyldig kamera feed URL")
return StreamingResponse(
_iter_mjpeg_frames(feed_url=feed_url, target_fps=fps),
media_type="multipart/x-mixed-replace; boundary=frame",
headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"},
)
@router.get("/mission/camera/status")
async def mission_camera_status():
feed_url = (MissionService.get_setting_value("mission_camera_feed_url", "") or "").strip()
enabled = str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true"
if not enabled:
return {"ok": False, "detail": "Kamera feed er ikke aktiveret", "enabled": False}
if not feed_url:
return {"ok": False, "detail": "Kamera feed URL mangler", "enabled": True}
if not _is_valid_feed_url(feed_url):
return {"ok": False, "detail": "Ugyldig kamera feed URL", "enabled": True}
probe = _probe_camera_stream(feed_url)
return {
"ok": bool(probe.get("ok")),
"detail": probe.get("detail") or "Ukendt status",
"enabled": True,
"feed_scheme": feed_url.split(":", 1)[0].lower() if ":" in feed_url else "unknown",
}
@router.put("/mission/camera/config")
async def update_mission_camera_config(config: MissionCameraConfigUpdate):
feed_url = (config.feed_url or "").strip()
camera_name = (config.camera_name or "Mission Kamera").strip() or "Mission Kamera"
spotlight_seconds = int(config.spotlight_seconds or 20)
spotlight_seconds = max(5, min(spotlight_seconds, 120))
if feed_url and not _is_valid_feed_url(feed_url):
raise HTTPException(status_code=400, detail="Ugyldig feed URL. Brug rtsp/http/https")
execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
(%s, %s, 'mission', 'Enable one camera feed in Mission Control', 'boolean', true),
(%s, %s, 'mission', 'Camera name for Mission Control', 'string', true),
(%s, %s, 'mission', 'Camera feed URL for Mission Control', 'string', true),
(%s, %s, 'mission', 'Camera spotlight duration in seconds for motion events', 'integer', true)
ON CONFLICT (key)
DO UPDATE SET
value = EXCLUDED.value,
updated_at = CURRENT_TIMESTAMP
""",
(
"mission_camera_enabled",
"true" if config.enabled else "false",
"mission_camera_name",
camera_name,
"mission_camera_feed_url",
feed_url,
"mission_camera_spotlight_seconds",
str(spotlight_seconds),
),
)
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
return {
"status": "ok",
"camera": {
"enabled": config.enabled,
"camera_name": camera_name,
"feed_url": feed_url,
"spotlight_seconds": spotlight_seconds,
},
}
@router.put("/mission/access-pin")
async def update_mission_access_pin(request: Request, payload: MissionAccessPinUpdate):
_require_authenticated_user(request)
new_pin = (payload.pin or "").strip()
if not _is_valid_access_pin(new_pin):
raise HTTPException(status_code=400, detail="PIN skal være 4-10 cifre")
execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, 'mission', 'Access PIN for Mission Control kiosk mode', 'string', false)
ON CONFLICT (key)
DO UPDATE SET
value = EXCLUDED.value,
updated_at = CURRENT_TIMESTAMP
""",
("mission_access_pin", new_pin),
)
return {"status": "ok", "message": "Mission PIN opdateret"}
@router.websocket("/mission/ws") @router.websocket("/mission/ws")
async def mission_ws(websocket: WebSocket): async def mission_ws(websocket: WebSocket):
token = websocket.query_params.get("token") token = websocket.query_params.get("token")
auth_header = (websocket.headers.get("authorization") or "").strip() auth_header = (websocket.headers.get("authorization") or "").strip()
if not token and auth_header.lower().startswith("bearer "): if not token and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip() token = auth_header.split(" ", 1)[1].strip()
if not token:
token = (websocket.cookies.get("access_token") or "").strip() or None
payload = AuthService.verify_token(token) if token else None payload = AuthService.verify_token(token) if token else None
if not payload:
access_cookie_token = (websocket.cookies.get("access_token") or "").strip() or None
payload = AuthService.verify_token(access_cookie_token) if access_cookie_token else None
if not payload:
mission_pin_cookie_token = (websocket.cookies.get("mission_pin_token") or "").strip() or None
payload = AuthService.verify_token(mission_pin_cookie_token) if mission_pin_cookie_token else None
if not payload: if not payload:
await websocket.close(code=1008) await websocket.close(code=1008)
return return
@ -453,3 +730,126 @@ async def mission_uptime_webhook(payload: MissionUptimeWebhook, request: Request
await mission_ws_manager.broadcast("live_feed_event", event_row) await mission_ws_manager.broadcast("live_feed_event", event_row)
return {"status": "ok", "normalized": normalized} return {"status": "ok", "normalized": normalized}
@router.post("/mission/webhook/camera/motion")
async def mission_camera_motion_webhook(
payload: MissionCameraMotionWebhook,
request: Request,
token: Optional[str] = Query(None),
):
_validate_mission_webhook_token(request, token)
raw_payload = dict(payload.payload or {})
motion_detected = bool(payload.motion)
if payload.event_type and str(payload.event_type).strip().lower() in {"no_motion", "idle", "clear"}:
motion_detected = False
camera_name = (payload.camera_name or MissionService.get_setting_value("mission_camera_name", "Mission Kamera") or "Mission Kamera").strip()
event_timestamp = payload.timestamp or datetime.utcnow()
event_timestamp_iso = event_timestamp.isoformat()
snapshot_url = (payload.snapshot_url or "").strip() or None
await mission_ws_manager.broadcast(
"camera_motion",
{
"camera_name": camera_name,
"motion": motion_detected,
"timestamp": event_timestamp_iso,
"snapshot_url": snapshot_url,
"payload": raw_payload,
},
)
return {
"status": "ok",
"camera_name": camera_name,
"motion": motion_detected,
}
@router.post("/mission/webhook/environment/temperature")
async def mission_environment_temperature_webhook(
payload: MissionTemperatureWebhook,
request: Request,
token: Optional[str] = Query(None),
):
_validate_mission_webhook_token(request, token)
sensor_id = (payload.sensor_id or "").strip() or None
sensor_name = (payload.sensor_name or "").strip() or sensor_id or "Temperatur"
unit = (payload.unit or "°C").strip() or "°C"
timestamp = payload.timestamp or datetime.utcnow()
raw_payload = dict(payload.payload or {})
reading = {
"sensor_id": sensor_id,
"sensor_name": sensor_name,
"temperature": float(payload.temperature),
"unit": unit,
"timestamp": timestamp.isoformat(),
"payload": raw_payload,
}
existing = MissionService.parse_json_setting("mission_environment_readings", [])
if not isinstance(existing, list):
existing = []
merged: list[Dict[str, Any]] = []
replaced = False
for item in existing:
if not isinstance(item, dict):
continue
item_sensor_id = str(item.get("sensor_id") or "").strip() or None
item_sensor_name = str(item.get("sensor_name") or "").strip()
# Keep one latest entry per sensor when possible.
if sensor_id and item_sensor_id == sensor_id and not replaced:
merged.append(reading)
replaced = True
continue
if (not sensor_id) and item_sensor_name and item_sensor_name == sensor_name and not replaced:
merged.append(reading)
replaced = True
continue
merged.append(item)
if not replaced:
merged.insert(0, reading)
merged = merged[:12]
execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, 'mission', 'Latest environment sensor readings for Mission Control', 'json', true)
ON CONFLICT (key)
DO UPDATE SET
value = EXCLUDED.value,
updated_at = CURRENT_TIMESTAMP
""",
("mission_environment_readings", json.dumps(merged, ensure_ascii=False)),
)
event_row = MissionService.insert_event(
event_type="environment_temperature",
title=f"Temperatur {sensor_name}: {payload.temperature:.1f}{unit}",
severity="info",
source="home_assistant",
payload=reading,
)
await mission_ws_manager.broadcast(
"mission_environment_temperature",
{"reading": reading, "environment_readings": merged},
)
if event_row:
await mission_ws_manager.broadcast("live_feed_event", event_row)
return {
"status": "ok",
"reading": reading,
"count": len(merged),
}

View File

@ -9,6 +9,8 @@ logger = logging.getLogger(__name__)
class MissionService: class MissionService:
MISSION_CASE_TYPES = {"ticket", "opgave", "ordre", "projekt", "service"}
@staticmethod @staticmethod
def _safe(label: str, func, default): def _safe(label: str, func, default):
try: try:
@ -240,6 +242,124 @@ class MissionService:
) )
return rows or [] return rows or []
@staticmethod
def _normalize_case_type(template_key: Optional[str]) -> str:
key = (template_key or "").strip().lower()
if not key:
return "opgave"
if "ticket" in key:
return "ticket"
if "ordre" in key or "order" in key:
return "ordre"
if "projekt" in key or "project" in key or "pipeline" in key:
return "projekt"
if "service" in key or "support" in key:
return "service"
if "opgave" in key or "task" in key:
return "opgave"
return "opgave"
@staticmethod
def get_important_cases(limit: int = 80) -> list[Dict[str, Any]]:
if not MissionService._table_exists("sag_sager"):
logger.warning("Mission table missing: sag_sager (important cases unavailable)")
return []
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.status,
s.priority,
s.deadline,
s.template_key,
s.created_at,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) <> 'afsluttet'
ORDER BY
CASE LOWER(COALESCE(s.priority, ''))
WHEN 'kritisk' THEN 5
WHEN 'critical' THEN 5
WHEN 'høj' THEN 4
WHEN 'hoj' THEN 4
WHEN 'high' THEN 4
WHEN 'medium' THEN 3
WHEN 'normal' THEN 2
WHEN 'lav' THEN 1
WHEN 'low' THEN 1
ELSE 2
END DESC,
s.deadline ASC NULLS LAST,
s.created_at DESC
LIMIT %s
""",
(limit,),
) or []
result: list[Dict[str, Any]] = []
for row in rows:
item = dict(row)
normalized_type = MissionService._normalize_case_type(item.get("template_key"))
if normalized_type not in MissionService.MISSION_CASE_TYPES:
normalized_type = "opgave"
item["case_type"] = normalized_type
result.append(item)
return result
@staticmethod
def get_recent_emails(limit: int = 25) -> list[Dict[str, Any]]:
if not MissionService._table_exists("email_messages"):
logger.warning("Mission table missing: email_messages (recent emails unavailable)")
return []
rows = execute_query(
"""
SELECT
id,
subject,
sender_name,
sender_email,
classification,
status,
linked_case_id,
received_date
FROM email_messages
WHERE deleted_at IS NULL
ORDER BY received_date DESC NULLS LAST, created_at DESC
LIMIT %s
""",
(limit,),
) or []
return [dict(row) for row in rows]
@staticmethod
def get_environment_readings(limit: int = 12) -> list[Dict[str, Any]]:
readings = MissionService.parse_json_setting("mission_environment_readings", [])
if not isinstance(readings, list):
return []
result: list[Dict[str, Any]] = []
for raw in readings:
if not isinstance(raw, dict):
continue
item = {
"sensor_id": str(raw.get("sensor_id") or "").strip() or None,
"sensor_name": str(raw.get("sensor_name") or "").strip() or "Sensor",
"temperature": raw.get("temperature"),
"unit": str(raw.get("unit") or "°C").strip() or "°C",
"timestamp": raw.get("timestamp"),
"payload": raw.get("payload") if isinstance(raw.get("payload"), dict) else {},
}
result.append(item)
return result[: max(1, int(limit))]
@staticmethod @staticmethod
def get_state() -> Dict[str, Any]: def get_state() -> Dict[str, Any]:
kpis_default = { kpis_default = {
@ -256,6 +376,9 @@ class MissionService:
"employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []), "employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []),
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []), "active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []), "live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
"important_cases": MissionService._safe("important_cases", lambda: MissionService.get_important_cases(80), []),
"recent_emails": MissionService._safe("recent_emails", lambda: MissionService.get_recent_emails(25), []),
"environment_readings": MissionService._safe("environment_readings", lambda: MissionService.get_environment_readings(12), []),
"config": { "config": {
"display_queues": MissionService._safe("config.display_queues", lambda: MissionService.parse_json_setting("mission_display_queues", []), []), "display_queues": MissionService._safe("config.display_queues", lambda: MissionService.parse_json_setting("mission_display_queues", []), []),
"sound_enabled": MissionService._safe( "sound_enabled": MissionService._safe(
@ -286,5 +409,25 @@ class MissionService:
lambda: MissionService.get_setting_value("mission_customer_filter", "") or "", lambda: MissionService.get_setting_value("mission_customer_filter", "") or "",
"", "",
), ),
"camera_enabled": MissionService._safe(
"config.camera_enabled",
lambda: str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true",
False,
),
"camera_name": MissionService._safe(
"config.camera_name",
lambda: MissionService.get_setting_value("mission_camera_name", "Mission Kamera") or "Mission Kamera",
"Mission Kamera",
),
"camera_feed_url": MissionService._safe(
"config.camera_feed_url",
lambda: MissionService.get_setting_value("mission_camera_feed_url", "") or "",
"",
),
"camera_spotlight_seconds": MissionService._safe(
"config.camera_spotlight_seconds",
lambda: max(5, min(int(MissionService.get_setting_value("mission_camera_spotlight_seconds", "20") or 20), 120)),
20,
),
}, },
} }

View File

@ -1,9 +1,13 @@
import logging import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from app.core.database import execute_query, execute_query_single 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() router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
@ -74,6 +78,119 @@ def _is_sales_group(group_names) -> bool:
for group in (group_names or []) 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'<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
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request): async def dashboard(request: Request):
""" """
@ -368,3 +485,8 @@ async def mission_control_dashboard(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)

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from app.core.database import execute_query from app.core.database import execute_query, execute_query_single
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from pydantic import BaseModel from pydantic import BaseModel
from datetime import date, datetime from datetime import date, datetime
@ -76,7 +76,7 @@ async def get_features(version: Optional[str] = None, status: Optional[str] = No
params.append(status) params.append(status)
query += " ORDER BY priority DESC, expected_date ASC" query += " ORDER BY priority DESC, expected_date ASC"
result = execute_query_single(query, tuple(params) if params else None) result = execute_query(query, tuple(params) if params else None)
return result or [] return result or []
@ -86,7 +86,7 @@ async def get_feature(feature_id: int):
result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,)) result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Feature not found") raise HTTPException(status_code=404, detail="Feature not found")
return result return result[0]
@router.post("/features", response_model=Feature) @router.post("/features", response_model=Feature)
@ -151,7 +151,7 @@ async def get_ideas(category: Optional[str] = None):
params.append(category) params.append(category)
query += " ORDER BY votes DESC, created_at DESC" query += " ORDER BY votes DESC, created_at DESC"
result = execute_query_single(query, tuple(params) if params else None) result = execute_query(query, tuple(params) if params else None)
return result or [] return result or []
@ -163,7 +163,7 @@ async def create_idea(idea: IdeaCreate):
VALUES (%s, %s, %s) VALUES (%s, %s, %s)
RETURNING * RETURNING *
""" """
result = execute_query(query, (idea.title, idea.description, idea.category)) result = execute_query_single(query, (idea.title, idea.description, idea.category))
logger.info(f"✅ Created idea: {idea.title}") logger.info(f"✅ Created idea: {idea.title}")
return result return result
@ -209,7 +209,7 @@ async def get_workflows(category: Optional[str] = None):
params.append(category) params.append(category)
query += " ORDER BY created_at DESC" query += " ORDER BY created_at DESC"
result = execute_query_single(query, tuple(params) if params else None) result = execute_query(query, tuple(params) if params else None)
return result or [] return result or []
@ -219,7 +219,7 @@ async def get_workflow(workflow_id: int):
result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,)) result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Workflow not found") raise HTTPException(status_code=404, detail="Workflow not found")
return result return result[0]
@router.post("/workflows", response_model=Workflow) @router.post("/workflows", response_model=Workflow)

View File

@ -174,6 +174,18 @@ class LinkEmailToSagRequest(BaseModel):
mark_processed: bool = True mark_processed: bool = True
class RewriteEmailTextRequest(BaseModel):
text: str
context: Optional[str] = "email"
class RewriteEmailTextResponse(BaseModel):
rewritten_text: str
model: Optional[str] = None
endpoint: Optional[str] = None
context: Optional[str] = None
@router.get("/emails/sag-options") @router.get("/emails/sag-options")
async def get_sag_assignment_options(): async def get_sag_assignment_options():
"""Return users and groups for SAG assignment controls in email UI.""" """Return users and groups for SAG assignment controls in email UI."""
@ -243,6 +255,32 @@ async def search_customers(q: str = Query(..., min_length=1), limit: int = Query
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/rewrite-text", response_model=RewriteEmailTextResponse)
async def rewrite_email_text(request: RewriteEmailTextRequest):
"""Rewrite email/case text via Ollama using the text_rewrite prompt."""
try:
input_text = (request.text or "").strip()
if not input_text:
raise HTTPException(status_code=400, detail="text is required")
result = await ollama_service.rewrite_text(input_text, request.context or "email")
if not result or result.get("error"):
detail = (result or {}).get("error") or "Could not rewrite text"
raise HTTPException(status_code=502, detail=detail)
return RewriteEmailTextResponse(
rewritten_text=result.get("rewritten_text", ""),
model=result.get("model"),
endpoint=result.get("endpoint"),
context=result.get("context"),
)
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error rewriting email text: %s", e)
raise HTTPException(status_code=500, detail="Failed to rewrite text")
@router.get("/emails/search-sager") @router.get("/emails/search-sager")
async def search_sager(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)): async def search_sager(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)):
"""Autocomplete SAG cases for linking emails to existing cases.""" """Autocomplete SAG cases for linking emails to existing cases."""
@ -330,7 +368,7 @@ async def list_emails(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/{email_id}", response_model=EmailDetail) @router.get("/emails/{email_id:int}", response_model=EmailDetail)
async def get_email(email_id: int): async def get_email(email_id: int):
"""Get email detail by ID""" """Get email detail by ID"""
try: try:
@ -443,7 +481,7 @@ async def download_attachment(email_id: int, attachment_id: int):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put("/emails/{email_id}") @router.put("/emails/{email_id:int}")
async def update_email(email_id: int, status: Optional[str] = None): async def update_email(email_id: int, status: Optional[str] = None):
"""Update email (archive, mark as read, etc)""" """Update email (archive, mark as read, etc)"""
try: try:
@ -1260,7 +1298,7 @@ JSON:"""
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete("/emails/{email_id}") @router.delete("/emails/{email_id:int}")
async def delete_email(email_id: int): async def delete_email(email_id: int):
"""Soft delete email""" """Soft delete email"""
try: try:
@ -1659,7 +1697,7 @@ async def update_classification(email_id: int, data: ClassificationUpdate):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete("/emails/{email_id}") @router.delete("/emails/{email_id:int}")
async def delete_email(email_id: int): async def delete_email(email_id: int):
"""Soft delete email""" """Soft delete email"""
try: try:

View File

@ -1498,12 +1498,21 @@ let emailSearchTimeout = null;
let autoRefreshInterval = null; let autoRefreshInterval = null;
let sagAssignmentOptions = { users: [], groups: [] }; let sagAssignmentOptions = { users: [], groups: [] };
let customerSearchHideTimeout = null; let customerSearchHideTimeout = null;
let pendingOpenEmailId = null;
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
console.log('📧 Email UI: DOMContentLoaded fired'); console.log('📧 Email UI: DOMContentLoaded fired');
console.log('Email list element:', document.getElementById('emailListBody')); console.log('Email list element:', document.getElementById('emailListBody'));
const params = new URLSearchParams(window.location.search);
const openEmailRaw = params.get('open') || params.get('email_id');
const openEmailId = Number(openEmailRaw || 0);
if (Number.isFinite(openEmailId) && openEmailId > 0) {
pendingOpenEmailId = openEmailId;
currentFilter = 'all';
}
loadEmails(); loadEmails();
loadStats(); loadStats();
setupEventListeners(); setupEventListeners();
@ -1649,6 +1658,18 @@ async function loadEmails(searchQuery = '') {
console.log('Loaded emails:', emails.length, 'items'); console.log('Loaded emails:', emails.length, 'items');
renderEmailList(emails); renderEmailList(emails);
if (pendingOpenEmailId) {
const emailToOpen = pendingOpenEmailId;
pendingOpenEmailId = null;
if (emails.some((email) => Number(email.id) === Number(emailToOpen))) {
await selectEmail(emailToOpen);
} else {
currentEmailId = emailToOpen;
await loadEmailDetail(emailToOpen);
}
}
} catch (error) { } catch (error) {
console.error('Failed to load emails:', error); console.error('Failed to load emails:', error);
showError('Kunne ikke indlæse emails: ' + error.message); showError('Kunne ikke indlæse emails: ' + error.message);

View File

@ -17,6 +17,7 @@ from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCr
from app.core.config import settings from app.core.config import settings
from app.services.email_service import EmailService from app.services.email_service import EmailService
from app.services.case_analysis_service import CaseAnalysisService from app.services.case_analysis_service import CaseAnalysisService
from app.services.ollama_service import ollama_service
try: try:
import extract_msg import extract_msg
@ -166,6 +167,18 @@ class QuickCreateRequest(BaseModel):
user_id: int user_id: int
class RewriteTextRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=10000)
context: Optional[str] = Field(default="case")
class RewriteTextResponse(BaseModel):
rewritten_text: str
model: Optional[str] = None
endpoint: Optional[str] = None
context: Optional[str] = None
class SagSendEmailRequest(BaseModel): class SagSendEmailRequest(BaseModel):
to: List[str] to: List[str]
subject: str = Field(..., min_length=1, max_length=998) subject: str = Field(..., min_length=1, max_length=998)
@ -335,6 +348,50 @@ async def analyze_quick_create(request: QuickCreateRequest):
logger.error(f"❌ QuickCreate analysis failed: {str(e)}", exc_info=True) logger.error(f"❌ QuickCreate analysis failed: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
@router.post("/sag/rewrite-text", response_model=RewriteTextResponse)
async def rewrite_sag_text(request: RewriteTextRequest):
"""Rewrite case/email text using Ollama with configurable prompt."""
try:
result = await ollama_service.rewrite_text(request.text, request.context or "case")
if not result or result.get("error"):
detail = (result or {}).get("error") or "Could not rewrite text"
raise HTTPException(status_code=502, detail=detail)
return RewriteTextResponse(
rewritten_text=result.get("rewritten_text", ""),
model=result.get("model"),
endpoint=result.get("endpoint"),
context=result.get("context"),
)
except HTTPException:
raise
except Exception as e:
logger.error("❌ Rewrite text failed: %s", e)
raise HTTPException(status_code=500, detail="Failed to rewrite text")
@router.post("/rewrite-text", response_model=RewriteTextResponse)
async def rewrite_text_global(request: RewriteTextRequest):
"""Global rewrite alias used by UI flows that should avoid feature-specific route conflicts."""
try:
result = await ollama_service.rewrite_text(request.text, request.context or "case")
if not result or result.get("error"):
detail = (result or {}).get("error") or "Could not rewrite text"
raise HTTPException(status_code=502, detail=detail)
return RewriteTextResponse(
rewritten_text=result.get("rewritten_text", ""),
model=result.get("model"),
endpoint=result.get("endpoint"),
context=result.get("context"),
)
except HTTPException:
raise
except Exception as e:
logger.error("❌ Global rewrite text failed: %s", e)
raise HTTPException(status_code=500, detail="Failed to rewrite text")
# ============================================================================ # ============================================================================
# SAGER - CRUD Operations # SAGER - CRUD Operations
# ============================================================================ # ============================================================================
@ -510,7 +567,7 @@ async def create_sag(data: dict):
logger.error("❌ Error creating case: %s", e) logger.error("❌ Error creating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to create case") raise HTTPException(status_code=500, detail="Failed to create case")
@router.get("/sag/{sag_id}") @router.get("/sag/{sag_id:int}")
async def get_sag(sag_id: int): async def get_sag(sag_id: int):
"""Get a specific case.""" """Get a specific case."""
try: try:
@ -741,7 +798,7 @@ async def delete_todo_step(step_id: int):
logger.error("❌ Error deleting todo step: %s", e) logger.error("❌ Error deleting todo step: %s", e)
raise HTTPException(status_code=500, detail="Failed to delete todo step") raise HTTPException(status_code=500, detail="Failed to delete todo step")
@router.patch("/sag/{sag_id}") @router.patch("/sag/{sag_id:int}")
async def update_sag(sag_id: int, updates: dict): async def update_sag(sag_id: int, updates: dict):
"""Update a case.""" """Update a case."""
try: try:
@ -958,7 +1015,7 @@ async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e) logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to update pipeline") raise HTTPException(status_code=500, detail="Failed to update pipeline")
@router.delete("/sag/{sag_id}") @router.delete("/sag/{sag_id:int}")
async def delete_sag(sag_id: int): async def delete_sag(sag_id: int):
"""Soft-delete a case.""" """Soft-delete a case."""
try: try:

View File

@ -37,6 +37,7 @@ def _fetch_assignment_users():
def _fetch_assignment_groups(): def _fetch_assignment_groups():
try:
return execute_query( return execute_query(
""" """
SELECT id, name SELECT id, name
@ -45,6 +46,9 @@ def _fetch_assignment_groups():
""", """,
() ()
) or [] ) or []
except Exception as e:
logger.warning("⚠️ Could not load assignment groups (groups table missing?): %s", e)
return []
def _coerce_optional_int(value: Optional[str]) -> Optional[int]: def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
@ -179,7 +183,39 @@ async def sager_liste(
params.append(assigned_group_id_int) params.append(assigned_group_id_int)
query += " ORDER BY s.created_at DESC" query += " ORDER BY s.created_at DESC"
try:
sager = execute_query(query, tuple(params)) sager = execute_query(query, tuple(params))
except Exception as e:
logger.warning("⚠️ Advanced SAG list query failed, using compatibility fallback: %s", e)
fallback_query = """
SELECT s.*,
c.name as customer_name,
'' as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
NULL::text AS next_todo_title,
NULL::timestamp AS next_todo_due_date
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL
"""
fallback_params = []
if status:
fallback_query += " AND s.status = %s"
fallback_params.append(status)
if customer_id_int:
fallback_query += " AND s.customer_id = %s"
fallback_params.append(customer_id_int)
if ansvarlig_bruger_id_int:
fallback_query += " AND s.ansvarlig_bruger_id = %s"
fallback_params.append(ansvarlig_bruger_id_int)
fallback_query += " ORDER BY s.created_at DESC"
sager = execute_query(fallback_query, tuple(fallback_params)) or []
# Fetch relations for all cases # Fetch relations for all cases
relations_query = """ relations_query = """
@ -191,7 +227,11 @@ async def sager_liste(
FROM sag_relationer sr FROM sag_relationer sr
WHERE sr.deleted_at IS NULL WHERE sr.deleted_at IS NULL
""" """
try:
all_relations = execute_query(relations_query, ()) all_relations = execute_query(relations_query, ())
except Exception as e:
logger.warning("⚠️ Could not load case relations: %s", e)
all_relations = []
child_ids = set() child_ids = set()
# Build relations map: {sag_id: [list of related sag_ids]} # Build relations map: {sag_id: [list of related sag_ids]}

View File

@ -2188,6 +2188,9 @@
<button class="btn btn-sm btn-outline-secondary" onclick="cancelBeskrivelsEdit()"> <button class="btn btn-sm btn-outline-secondary" onclick="cancelBeskrivelsEdit()">
<i class="bi bi-x me-1"></i>Annuller <i class="bi bi-x me-1"></i>Annuller
</button> </button>
<button id="beskrivelse-rewrite-btn" type="button" class="btn btn-sm btn-outline-primary" onclick="rewriteCaseDescriptionWithApproval()">
<i class="bi bi-magic me-1"></i>Renskriv med AI
</button>
<button id="beskrivelse-save-btn" class="btn btn-sm btn-primary" onclick="saveBeskrivelsEdit()"> <button id="beskrivelse-save-btn" class="btn btn-sm btn-primary" onclick="saveBeskrivelsEdit()">
<i class="bi bi-check2 me-1"></i>Gem <i class="bi bi-check2 me-1"></i>Gem
</button> </button>
@ -4720,7 +4723,12 @@
</div> </div>
</div> </div>
<div> <div>
<label for="caseEmailBody" class="form-label form-label-sm mb-1">Besked</label> <div class="d-flex justify-content-between align-items-center mb-1">
<label for="caseEmailBody" class="form-label form-label-sm mb-0">Besked</label>
<button id="caseEmailRewriteBtn" type="button" class="btn btn-outline-primary btn-sm">
<i class="bi bi-magic me-1"></i>Renskriv med AI
</button>
</div>
<textarea class="form-control form-control-sm" id="caseEmailBody" rows="10" placeholder="Skriv besked..."></textarea> <textarea class="form-control form-control-sm" id="caseEmailBody" rows="10" placeholder="Skriv besked..."></textarea>
</div> </div>
</div> </div>
@ -4736,6 +4744,30 @@
</div> </div>
</div> </div>
<div class="modal fade" id="rewritePreviewModal" tabindex="-1" aria-labelledby="rewritePreviewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="rewritePreviewModalLabel"><i class="bi bi-pencil-square me-2"></i>AI forslag til renskrivning</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="rewritePreviewSummary" class="small text-muted mb-3"></div>
<div id="rewritePreviewNoChanges" class="alert alert-info d-none mb-3">AI fandt ingen ændringer.</div>
<div id="rewritePreviewList" class="d-flex flex-column gap-2"></div>
</div>
<div class="modal-footer d-flex justify-content-between">
<small id="rewritePreviewSelectionInfo" class="text-muted">Ingen ændringer valgt</small>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Annuller</button>
<button type="button" id="rewriteApplySelectedBtn" class="btn btn-outline-primary btn-sm">Godkend valgte</button>
<button type="button" id="rewriteApplyAllBtn" class="btn btn-primary btn-sm">Godkend alle</button>
</div>
</div>
</div>
</div>
</div>
<!-- Solution Tab --> <!-- Solution Tab -->
<div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}" style="display:none;"> <div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}" style="display:none;">
<!-- Nextcloud Integration Box --> <!-- Nextcloud Integration Box -->
@ -8583,6 +8615,200 @@
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
let rewriteReviewState = null;
function extractRewriteBody(rawText, context) {
const text = String(rawText || '').trim();
if (!text) return '';
if (context === 'email') {
const bodyMatch = text.match(/(?:^|\n)Besked:\s*\n([\s\S]*)$/i);
if (bodyMatch?.[1]) return bodyMatch[1].trim();
return text;
}
if (context === 'case') {
const descMatch = text.match(/(?:^|\n)Beskrivelse:\s*\n([\s\S]*)$/i);
if (descMatch?.[1]) return descMatch[1].trim();
return text;
}
return text;
}
function buildLineDiff(originalText, rewrittenText) {
const originalLines = String(originalText || '').split('\n');
const rewrittenLines = String(rewrittenText || '').split('\n');
const maxLen = Math.max(originalLines.length, rewrittenLines.length);
const changes = [];
for (let idx = 0; idx < maxLen; idx += 1) {
const before = originalLines[idx] ?? '';
const after = rewrittenLines[idx] ?? '';
if (before !== after) {
changes.push({ index: idx, before, after });
}
}
return { changes, originalLines, rewrittenLines };
}
function updateRewriteSelectionInfo() {
const infoEl = document.getElementById('rewritePreviewSelectionInfo');
const selectedCount = document.querySelectorAll('.rewrite-change-check:checked').length;
const totalCount = rewriteReviewState?.changes?.length || 0;
if (!infoEl) return;
infoEl.textContent = `${selectedCount} af ${totalCount} ændringer valgt`;
}
function renderRewritePreview(changes) {
const listEl = document.getElementById('rewritePreviewList');
const noChangesEl = document.getElementById('rewritePreviewNoChanges');
if (!listEl || !noChangesEl) return;
if (!changes.length) {
listEl.innerHTML = '';
noChangesEl.classList.remove('d-none');
return;
}
noChangesEl.classList.add('d-none');
listEl.innerHTML = changes.map((change, i) => `
<div class="card border-0 shadow-sm">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="form-check">
<input class="form-check-input rewrite-change-check" type="checkbox" value="${change.index}" id="rewriteChange_${change.index}" checked>
<label class="form-check-label small fw-semibold" for="rewriteChange_${change.index}">
Ændring ${i + 1} (linje ${change.index + 1})
</label>
</div>
</div>
<div class="row g-2">
<div class="col-md-6">
<div class="small text-muted mb-1">Før</div>
<div class="border rounded p-2 bg-light" style="white-space: pre-wrap; min-height: 44px;">${escapeHtml(change.before) || '<span class="text-muted fst-italic">(tom)</span>'}</div>
</div>
<div class="col-md-6">
<div class="small text-muted mb-1">Efter</div>
<div class="border rounded p-2" style="white-space: pre-wrap; min-height: 44px; background: rgba(15,76,117,0.08);">${escapeHtml(change.after) || '<span class="text-muted fst-italic">(tom)</span>'}</div>
</div>
</div>
</div>
</div>
`).join('');
listEl.querySelectorAll('.rewrite-change-check').forEach((input) => {
input.addEventListener('change', updateRewriteSelectionInfo);
});
updateRewriteSelectionInfo();
}
function applyRewriteChanges(mode) {
if (!rewriteReviewState) return;
const { originalLines, rewrittenLines, applyToTarget } = rewriteReviewState;
if (mode === 'all') {
applyToTarget(rewrittenLines.join('\n'));
return;
}
const selectedIndexes = new Set(
Array.from(document.querySelectorAll('.rewrite-change-check:checked'))
.map((el) => Number(el.value))
.filter((val) => Number.isInteger(val) && val >= 0)
);
const merged = [...originalLines];
for (let idx = 0; idx < rewrittenLines.length; idx += 1) {
if (selectedIndexes.has(idx)) {
merged[idx] = rewrittenLines[idx] ?? '';
}
}
applyToTarget(merged.join('\n'));
}
function openRewriteReviewModal({ title, originalText, rewrittenText, applyToTarget }) {
const summaryEl = document.getElementById('rewritePreviewSummary');
const applyAllBtn = document.getElementById('rewriteApplyAllBtn');
const applySelectedBtn = document.getElementById('rewriteApplySelectedBtn');
const modalEl = document.getElementById('rewritePreviewModal');
if (!summaryEl || !applyAllBtn || !applySelectedBtn || !modalEl) return;
const diff = buildLineDiff(originalText, rewrittenText);
rewriteReviewState = {
...diff,
applyToTarget,
};
summaryEl.textContent = `${title}: ${diff.changes.length} foreslaaede ændringer.`;
renderRewritePreview(diff.changes);
applyAllBtn.disabled = !diff.changes.length;
applySelectedBtn.disabled = !diff.changes.length;
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
}
async function requestRewriteSuggestion(endpoint, text, context) {
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, context })
});
if (!response.ok) {
let detail = `HTTP ${response.status}`;
try {
const err = await response.json();
if (err?.detail) detail = err.detail;
} catch (_) {}
throw new Error(detail);
}
return response.json();
}
window.rewriteCaseEmailWithApproval = async function () {
const bodyInput = document.getElementById('caseEmailBody');
const btn = document.getElementById('caseEmailRewriteBtn');
if (!bodyInput) return;
const source = (bodyInput.value || '').trim();
if (!source) {
alert('Skriv en besked først.');
return;
}
const originalHtml = btn?.innerHTML || '';
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
}
try {
const payload = await requestRewriteSuggestion('/api/v1/emails/rewrite-text', source, 'email');
const rewritten = extractRewriteBody(payload?.rewritten_text || '', 'email');
openRewriteReviewModal({
title: 'Email-tekst',
originalText: source,
rewrittenText: rewritten,
applyToTarget: (nextText) => {
bodyInput.value = nextText;
bootstrap.Modal.getOrCreateInstance(document.getElementById('rewritePreviewModal')).hide();
}
});
} catch (error) {
console.error(error);
alert(`Kunne ikke renskrive email: ${error.message || 'Ukendt fejl'}`);
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = originalHtml;
}
}
};
function getDefaultCaseRecipient() { function getDefaultCaseRecipient() {
const primaryContact = document.querySelector('.contact-row[data-is-primary="true"][data-email]'); const primaryContact = document.querySelector('.contact-row[data-is-primary="true"][data-email]');
if (primaryContact?.dataset?.email) { if (primaryContact?.dataset?.email) {
@ -9289,6 +9515,21 @@
caseEmailSendBtn.addEventListener('click', sendCaseEmail); caseEmailSendBtn.addEventListener('click', sendCaseEmail);
} }
const caseEmailRewriteBtn = document.getElementById('caseEmailRewriteBtn');
if (caseEmailRewriteBtn) {
caseEmailRewriteBtn.addEventListener('click', rewriteCaseEmailWithApproval);
}
const rewriteApplyAllBtn = document.getElementById('rewriteApplyAllBtn');
if (rewriteApplyAllBtn) {
rewriteApplyAllBtn.addEventListener('click', () => applyRewriteChanges('all'));
}
const rewriteApplySelectedBtn = document.getElementById('rewriteApplySelectedBtn');
if (rewriteApplySelectedBtn) {
rewriteApplySelectedBtn.addEventListener('click', () => applyRewriteChanges('selected'));
}
const caseEmailComposeModal = document.getElementById('caseEmailComposeModal'); const caseEmailComposeModal = document.getElementById('caseEmailComposeModal');
if (caseEmailComposeModal) { if (caseEmailComposeModal) {
caseEmailComposeModal.addEventListener('show.bs.modal', () => { caseEmailComposeModal.addEventListener('show.bs.modal', () => {
@ -10781,6 +11022,86 @@
const SAG_ID = {{ case.id }}; const SAG_ID = {{ case.id }};
let _historyLoaded = false; let _historyLoaded = false;
window.rewriteCaseDescriptionWithApproval = async function () {
const ta = document.getElementById('beskrivelse-textarea');
const rewriteBtn = document.getElementById('beskrivelse-rewrite-btn');
if (!ta) return;
const source = (ta.value || '').trim();
if (!source) {
if (typeof showNotification === 'function') showNotification('Skriv en beskrivelse først', 'warning');
else alert('Skriv en beskrivelse først');
return;
}
const originalHtml = rewriteBtn?.innerHTML || '';
if (rewriteBtn) {
rewriteBtn.disabled = true;
rewriteBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
}
try {
const rewriteEndpoints = ['/api/v1/rewrite-text', '/api/v1/sag/rewrite-text', '/api/v1/emails/rewrite-text'];
let payload = null;
let lastError = null;
for (const endpoint of rewriteEndpoints) {
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: source, context: 'case' })
});
if (response.ok) {
payload = await response.json();
lastError = null;
break;
}
let detail = `HTTP ${response.status}`;
try {
const err = await response.json();
if (err?.detail) detail = err.detail;
} catch (_) {}
lastError = new Error(detail);
// Retry next endpoint for common route mismatch cases.
if (![404, 405].includes(response.status)) {
break;
}
}
if (!payload) {
throw lastError || new Error('Kunne ikke hente renskrivningsforslag');
}
const rewrittenRaw = String(payload?.rewritten_text || '').trim();
const descMatch = rewrittenRaw.match(/(?:^|\n)Beskrivelse:\s*\n([\s\S]*)$/i);
const rewritten = descMatch?.[1] ? descMatch[1].trim() : rewrittenRaw;
openRewriteReviewModal({
title: 'Sagsbeskrivelse',
originalText: source,
rewrittenText: rewritten,
applyToTarget: (nextText) => {
ta.value = nextText;
bootstrap.Modal.getOrCreateInstance(document.getElementById('rewritePreviewModal')).hide();
}
});
} catch (e) {
console.error(e);
if (typeof showNotification === 'function') showNotification('Kunne ikke renskrive beskrivelse', 'error');
else alert(`Kunne ikke renskrive beskrivelse: ${e.message || 'Ukendt fejl'}`);
} finally {
if (rewriteBtn) {
rewriteBtn.disabled = false;
rewriteBtn.innerHTML = originalHtml;
}
}
};
window.startBeskrivelsEdit = function () { window.startBeskrivelsEdit = function () {
const current = document.getElementById('beskrivelse-text').innerText.trim(); const current = document.getElementById('beskrivelse-text').innerText.trim();
document.getElementById('beskrivelse-textarea').value = current; document.getElementById('beskrivelse-textarea').value = current;

View File

@ -140,6 +140,126 @@ Output: {
"confidence": 0.95 "confidence": 0.95
}""") }""")
def _get_text_rewrite_prompt(self) -> str:
"""Return rewrite prompt with optional DB override from ai_prompts."""
default_prompt = """Du er en dansk skriveassistent for IT-support.
Din opgave er at renskrive en tekst til klart, professionelt og venligt dansk.
Teksten kan være enten en e-mail eller en sagsbeskrivelse.
Regler:
1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer.
2. Ret stavefejl, tegnsætning og grammatik.
3. Gør teksten kortere og mere præcis, men uden at fjerne vigtig information.
4. Fjern fyldord, gentagelser og intern støj.
5. Bevar tone og intention: neutral, serviceminded og professionel.
6. Opfind aldrig nye oplysninger.
7. Hvis input er e-mail: returner i formatet:
Emne: <kort forbedret emne>
Besked:
<renskrevet e-mailtekst>
8. Hvis input er sagsbeskrivelse: returner i formatet:
Titel: <kort præcis titel>
Beskrivelse:
<renskrevet sagsbeskrivelse>
Output:
- Returner KUN den renskrevne tekst i korrekt format.
- Ingen forklaringer før eller efter.
"""
try:
row = execute_query_single(
"SELECT prompt_text FROM ai_prompts WHERE key = %s",
("text_rewrite",),
)
custom_prompt = (row or {}).get("prompt_text") if row else None
if custom_prompt and str(custom_prompt).strip():
return str(custom_prompt).strip()
except Exception as e:
logger.warning("⚠️ Could not load custom text rewrite prompt: %s", e)
return default_prompt
async def rewrite_text(self, text: str, context: str = "auto") -> Dict:
"""Rewrite Danish email/case text using Ollama and configured prompt."""
input_text = (text or "").strip()
if not input_text:
return {"error": "Input text is empty", "confidence": 0.0}
normalized_context = (context or "auto").strip().lower()
if normalized_context not in {"auto", "email", "case"}:
normalized_context = "auto"
system_prompt = self._get_text_rewrite_prompt()
context_hint = {
"auto": "Auto-detekter om teksten er en e-mail eller sagsbeskrivelse.",
"email": "Behandl teksten som e-mail.",
"case": "Behandl teksten som sagsbeskrivelse.",
}[normalized_context]
user_message = (
f"{context_hint}\n\n"
"Renskriv følgende tekst:\n"
f"{input_text}\n"
)
try:
import httpx
model_normalized = (self.model or "").strip().lower()
use_chat_api = model_normalized.startswith("qwen")
async with httpx.AsyncClient(timeout=120.0) as client:
if use_chat_api:
response = await client.post(
f"{self.endpoint}/api/chat",
json={
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
"stream": False,
"options": {"temperature": 0.2, "top_p": 0.9, "num_predict": 1000},
},
)
else:
response = await client.post(
f"{self.endpoint}/api/generate",
json={
"model": self.model,
"prompt": f"{system_prompt}\n\nBrugerinput:\n{user_message}",
"stream": False,
"options": {"temperature": 0.2, "top_p": 0.9, "num_predict": 1000},
},
)
if response.status_code != 200:
return {
"error": f"Ollama returned status {response.status_code}: {response.text[:300]}",
"confidence": 0.0,
}
payload = response.json()
if use_chat_api:
message_data = payload.get("message", {}) if isinstance(payload, dict) else {}
rewritten_text = (message_data.get("content") or message_data.get("thinking") or "").strip()
else:
rewritten_text = str((payload or {}).get("response") or "").strip()
if not rewritten_text:
return {"error": "Ollama returned empty response", "confidence": 0.0}
return {
"rewritten_text": rewritten_text,
"model": self.model,
"endpoint": self.endpoint,
"context": normalized_context,
"confidence": 1.0,
}
except Exception as e:
logger.error("❌ Ollama text rewrite failed: %s", e)
return {"error": f"Ollama rewrite failed: {str(e)}", "confidence": 0.0}
async def extract_from_text(self, text: str) -> Dict: async def extract_from_text(self, text: str) -> Dict:
""" """
Extract structured invoice data from text using Ollama Extract structured invoice data from text using Ollama

View File

@ -221,6 +221,29 @@ async def update_setting(key: str, setting: SettingUpdate):
) )
) )
# Mission camera settings may not exist on older hubs before migration.
if not result and key in {"mission_camera_enabled", "mission_camera_name", "mission_camera_feed_url", "mission_camera_spotlight_seconds", "mission_access_pin"}:
defaults = {
"mission_camera_enabled": ("mission", "Enable one camera feed in Mission Control", "boolean", True),
"mission_camera_name": ("mission", "Display name for Mission Control camera feed", "string", True),
"mission_camera_feed_url": ("mission", "Feed URL for Mission Control camera", "string", True),
"mission_camera_spotlight_seconds": ("mission", "Camera spotlight duration in seconds for motion events", "integer", True),
"mission_access_pin": ("mission", "Access PIN for Mission Control kiosk mode", "string", False),
}
category, description, value_type, is_public = defaults[key]
result = execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (key)
DO UPDATE SET
value = EXCLUDED.value,
updated_at = CURRENT_TIMESTAMP
RETURNING *
""",
(key, setting.value, category, description, value_type, is_public),
)
if not result: if not result:
raise HTTPException(status_code=404, detail="Setting not found") raise HTTPException(status_code=404, detail="Setting not found")
@ -473,6 +496,42 @@ Output: [Fakturastekst]""",
"num_predict": 500 "num_predict": 500
} }
}, },
"text_rewrite": {
"name": "✍️ Omskrivning (Email + Sagsbeskrivelse)",
"description": "Renskriver tekst til professionelt dansk for både e-mails og sagsbeskrivelser, uden at ændre fakta.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Du er en dansk skriveassistent for IT-support.
Din opgave er at renskrive en tekst til klart, professionelt og venligt dansk.
Teksten kan være enten en e-mail eller en sagsbeskrivelse.
Regler:
1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer.
2. Ret stavefejl, tegnsætning og grammatik.
3. Gør teksten kortere og mere præcis, men uden at fjerne vigtig information.
4. Fjern fyldord, gentagelser og intern støj.
5. Bevar tone og intention: neutral, serviceminded og professionel.
6. Opfind aldrig nye oplysninger.
7. Hvis input er e-mail: returner i formatet:
Emne: <kort forbedret emne>
Besked:
<renskrevet e-mailtekst>
8. Hvis input er sagsbeskrivelse: returner i formatet:
Titel: <kort præcis titel>
Beskrivelse:
<renskrevet sagsbeskrivelse>
Output:
- Returner KUN den renskrevne tekst i korrekt format.
- Ingen forklaringer før eller efter.
""",
"parameters": {
"temperature": 0.2,
"top_p": 0.9,
"num_predict": 800
}
},
"kb_generation": { "kb_generation": {
"name": "📚 Vidensbank Generator (Solution to Article)", "name": "📚 Vidensbank Generator (Solution to Article)",
"description": "Omdanner en løst ticket til en generel vejledning til vidensbanken.", "description": "Omdanner en løst ticket til en generel vejledning til vidensbanken.",
@ -594,6 +653,7 @@ def _get_test_input_for_prompt(key: str) -> str:
"invoice_extraction": "FAKTURA 2026-1001 fra Demo A/S. CVR 12345678. Total 1.250,00 DKK inkl moms.", "invoice_extraction": "FAKTURA 2026-1001 fra Demo A/S. CVR 12345678. Total 1.250,00 DKK inkl moms.",
"ticket_classification": "Emne: Kan ikke logge på VPN. Beskrivelse: Flere brugere er ramt siden i morges.", "ticket_classification": "Emne: Kan ikke logge på VPN. Beskrivelse: Flere brugere er ramt siden i morges.",
"ticket_summary": "Bruger havde netværksfejl. Router genstartet og DNS opdateret. Forbindelse virker nu stabilt.", "ticket_summary": "Bruger havde netværksfejl. Router genstartet og DNS opdateret. Forbindelse virker nu stabilt.",
"text_rewrite": "emne: printer virker ikke\nhej\nvi har stadig problem med printeren hos norva24 i hellerup, den stopper hele tiden og brugerne kan ikke printe, kan i kigge på det hurtigst muligt\nvh dennis",
"kb_generation": "Problem: Outlook åbner ikke. Løsning: Reparer Office installation og nulstil profil.", "kb_generation": "Problem: Outlook åbner ikke. Løsning: Reparer Office installation og nulstil profil.",
"troubleshooting_assistant": "Server svarer langsomt efter opdatering. CPU er høj, disk IO er normal.", "troubleshooting_assistant": "Server svarer langsomt efter opdatering. CPU er høj, disk IO er normal.",
"sentiment_analysis": "Jeg er meget frustreret, systemet er nede igen og vi mister kunder!", "sentiment_analysis": "Jeg er meget frustreret, systemet er nede igen og vi mister kunder!",

View File

@ -113,6 +113,9 @@
<a class="nav-link" href="#modules" data-tab="modules"> <a class="nav-link" href="#modules" data-tab="modules">
<i class="bi bi-box-seam me-2"></i>Moduler <i class="bi bi-box-seam me-2"></i>Moduler
</a> </a>
<a class="nav-link" href="#mission" data-tab="mission">
<i class="bi bi-broadcast-pin me-2"></i>Mission
</a>
<a class="nav-link" href="#system" data-tab="system"> <a class="nav-link" href="#system" data-tab="system">
<i class="bi bi-gear me-2"></i>System <i class="bi bi-gear me-2"></i>System
</a> </a>
@ -1099,6 +1102,75 @@ async def scan_document(file_path: str):
</div> </div>
</div> </div>
<!-- Mission Settings -->
<div class="tab-pane fade" id="mission">
<div class="card p-4 mb-4">
<h5 class="mb-3 fw-bold">Mission Kamera</h5>
<p class="text-muted mb-3">Kamera-konfiguration flyttet hertil, så Mission Control kun viser live drift.</p>
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label">Aktiver kamera feed</label>
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" id="missionCameraEnabled">
</div>
</div>
<div class="col-md-3">
<label class="form-label" for="missionCameraName">Kamera navn</label>
<input type="text" class="form-control" id="missionCameraName" placeholder="Mission Kamera">
</div>
<div class="col-md-6">
<label class="form-label" for="missionCameraFeedUrl">Feed URL</label>
<input type="text" class="form-control" id="missionCameraFeedUrl" placeholder="rtsp:// eller https://...">
<div class="form-text">Understøtter rtsp://, http:// og https://</div>
</div>
<div class="col-md-3">
<label class="form-label" for="missionCameraSpotlightSeconds">Spotlight varighed (sek.)</label>
<input type="number" class="form-control" id="missionCameraSpotlightSeconds" min="5" max="120" step="1" value="20">
<div class="form-text">Forstørret kameravindue efter motion webhook.</div>
</div>
<div class="col-12 d-flex gap-2 flex-wrap">
<button class="btn btn-primary" id="saveMissionCameraSettingsBtn">
<i class="bi bi-save me-2"></i>Gem kamera
</button>
<button class="btn btn-outline-secondary" type="button" id="copyMissionWebhookBtn">
<i class="bi bi-clipboard me-2"></i>Kopier motion webhook URL
</button>
<button class="btn btn-outline-warning" type="button" id="diagnoseMissionCameraBtn">
<i class="bi bi-activity me-2"></i>Kør diagnose
</button>
</div>
<div class="col-12">
<div id="missionSettingsFeedback" class="form-text"></div>
</div>
<div class="col-12" id="missionCameraDiagnosticsWrap" style="display:none;">
<div class="border rounded p-2 bg-light">
<strong class="small">Kamera diagnose</strong>
<div id="missionCameraDiagnosticsText" class="small text-muted mt-1">Ingen diagnose kørt endnu.</div>
</div>
</div>
</div>
</div>
<div class="card p-4">
<h5 class="mb-3 fw-bold">Mission PIN</h5>
<p class="text-muted mb-3">PIN bruges til kiosk-adgang på Mission Control uden almindelig login.</p>
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label" for="missionAccessPinInput">Ny PIN (4-10 cifre)</label>
<input type="password" class="form-control" id="missionAccessPinInput" inputmode="numeric" maxlength="10" placeholder="fx 1234">
</div>
<div class="col-md-4 d-flex gap-2">
<button class="btn btn-primary" id="saveMissionPinSettingsBtn">
<i class="bi bi-shield-lock me-2"></i>Gem PIN
</button>
</div>
<div class="col-12">
<div id="missionPinSettingsFeedback" class="form-text"></div>
</div>
</div>
</div>
</div>
<!-- System Settings --> <!-- System Settings -->
<div class="tab-pane fade" id="system"> <div class="tab-pane fade" id="system">
<div class="card p-4 mb-4"> <div class="card p-4 mb-4">
@ -1539,6 +1611,214 @@ function renderTelefoniSettings() {
buildYealinkActionUrls(); buildYealinkActionUrls();
} }
function setOrAddSettingInCache(key, value) {
const existing = allSettings.find(s => s.key === key);
if (existing) {
existing.value = value;
return;
}
allSettings.push({ key, value });
}
function setMissionSettingsFeedback(message, type = 'muted') {
const feedback = document.getElementById('missionSettingsFeedback');
if (!feedback) return;
const cls = type === 'error' ? 'text-danger' : type === 'success' ? 'text-success' : 'text-muted';
feedback.className = `form-text ${cls}`;
feedback.textContent = message;
}
function setMissionPinFeedback(message, type = 'muted') {
const feedback = document.getElementById('missionPinSettingsFeedback');
if (!feedback) return;
const cls = type === 'error' ? 'text-danger' : type === 'success' ? 'text-success' : 'text-muted';
feedback.className = `form-text ${cls}`;
feedback.textContent = message;
}
function renderMissionSettings() {
const enabled = document.getElementById('missionCameraEnabled');
const name = document.getElementById('missionCameraName');
const feed = document.getElementById('missionCameraFeedUrl');
const spotlight = document.getElementById('missionCameraSpotlightSeconds');
if (!enabled || !name || !feed || !spotlight) return;
enabled.checked = getSettingValue('mission_camera_enabled', 'false') === 'true';
name.value = getSettingValue('mission_camera_name', 'Mission Kamera');
feed.value = getSettingValue('mission_camera_feed_url', '');
spotlight.value = String(Math.max(5, Math.min(parseInt(getSettingValue('mission_camera_spotlight_seconds', '20'), 10) || 20, 120)));
setMissionSettingsFeedback('');
setMissionPinFeedback('');
}
async function saveMissionCameraSettings() {
const enabledEl = document.getElementById('missionCameraEnabled');
const nameEl = document.getElementById('missionCameraName');
const feedEl = document.getElementById('missionCameraFeedUrl');
const spotlightEl = document.getElementById('missionCameraSpotlightSeconds');
const btn = document.getElementById('saveMissionCameraSettingsBtn');
if (!enabledEl || !nameEl || !feedEl || !spotlightEl || !btn) return;
const spotlightSeconds = Math.max(5, Math.min(parseInt(spotlightEl.value || '20', 10) || 20, 120));
spotlightEl.value = String(spotlightSeconds);
const payload = {
enabled: !!enabledEl.checked,
camera_name: (nameEl.value || '').trim() || 'Mission Kamera',
feed_url: (feedEl.value || '').trim(),
spotlight_seconds: spotlightSeconds,
};
if (payload.feed_url && !/^(rtsp|https?):\/\//i.test(payload.feed_url)) {
setMissionSettingsFeedback('Ugyldig feed URL. Brug rtsp/http/https.', 'error');
return;
}
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Gemmer...';
setMissionSettingsFeedback('Gemmer kameraindstillinger...');
try {
const response = await fetch('/api/v1/mission/camera/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok && (response.status === 404 || response.status === 405)) {
const updates = [
{ key: 'mission_camera_enabled', value: payload.enabled ? 'true' : 'false' },
{ key: 'mission_camera_name', value: payload.camera_name },
{ key: 'mission_camera_feed_url', value: payload.feed_url },
{ key: 'mission_camera_spotlight_seconds', value: String(payload.spotlight_seconds) },
];
for (const setting of updates) {
const fallback = await fetch(`/api/v1/settings/${encodeURIComponent(setting.key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: setting.value })
});
if (!fallback.ok) {
const detail = await getErrorMessage(fallback, `Kunne ikke gemme ${setting.key}`);
throw new Error(detail);
}
}
} else if (!response.ok) {
const detail = await getErrorMessage(response, 'Kunne ikke gemme Mission kamera');
throw new Error(detail);
}
setOrAddSettingInCache('mission_camera_enabled', payload.enabled ? 'true' : 'false');
setOrAddSettingInCache('mission_camera_name', payload.camera_name);
setOrAddSettingInCache('mission_camera_feed_url', payload.feed_url);
setOrAddSettingInCache('mission_camera_spotlight_seconds', String(payload.spotlight_seconds));
setMissionSettingsFeedback('Mission kamera gemt.', 'success');
showNotification('Mission kamera gemt', 'success');
} catch (error) {
setMissionSettingsFeedback(error.message || 'Kunne ikke gemme Mission kamera', 'error');
showNotification('Kunne ikke gemme Mission kamera', 'error');
} finally {
btn.disabled = false;
btn.innerHTML = original;
}
}
async function copyMissionWebhookUrl() {
const webhookUrl = `${window.location.origin}/api/v1/mission/webhook/camera/motion?token=DIN_TOKEN_HER`;
try {
await navigator.clipboard.writeText(webhookUrl);
setMissionSettingsFeedback('Webhook URL kopieret til clipboard.', 'success');
} catch {
setMissionSettingsFeedback(`Webhook URL: ${webhookUrl}`);
}
}
async function diagnoseMissionCamera() {
const btn = document.getElementById('diagnoseMissionCameraBtn');
const wrap = document.getElementById('missionCameraDiagnosticsWrap');
const text = document.getElementById('missionCameraDiagnosticsText');
if (!btn || !wrap || !text) return;
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Tester...';
wrap.style.display = '';
text.textContent = 'Kører diagnose...';
try {
const response = await fetch('/api/v1/mission/camera/status');
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const detail = data.detail || `Fejl (${response.status})`;
text.textContent = detail;
setMissionSettingsFeedback('Diagnose fejlede.', 'error');
return;
}
text.textContent = `Status: ${data.ok ? 'OK' : 'FEJL'} | Enabled: ${data.enabled ? 'ja' : 'nej'} | Type: ${data.feed_scheme || 'ukendt'} | Detail: ${data.detail || '-'}`;
setMissionSettingsFeedback(data.ok ? 'Diagnose: stream ser OK ud.' : 'Diagnose: stream har fejl.', data.ok ? 'success' : 'error');
} catch (error) {
text.textContent = `Netværksfejl: ${error.message || 'Ukendt fejl'}`;
setMissionSettingsFeedback('Diagnose fejlede.', 'error');
} finally {
btn.disabled = false;
btn.innerHTML = original;
}
}
async function saveMissionPinSettings() {
const pinInput = document.getElementById('missionAccessPinInput');
const btn = document.getElementById('saveMissionPinSettingsBtn');
if (!pinInput || !btn) return;
const pin = (pinInput.value || '').trim();
if (!/^\d{4,10}$/.test(pin)) {
setMissionPinFeedback('PIN skal være 4-10 cifre.', 'error');
return;
}
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Gemmer...';
setMissionPinFeedback('Gemmer PIN...');
try {
const response = await fetch('/api/v1/mission/access-pin', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin })
});
if (!response.ok && (response.status === 404 || response.status === 405)) {
const fallback = await fetch('/api/v1/settings/mission_access_pin', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: pin })
});
if (!fallback.ok) {
const detail = await getErrorMessage(fallback, 'Kunne ikke gemme PIN');
throw new Error(detail);
}
} else if (!response.ok) {
const detail = await getErrorMessage(response, 'Kunne ikke gemme PIN');
throw new Error(detail);
}
setOrAddSettingInCache('mission_access_pin', pin);
pinInput.value = '';
setMissionPinFeedback('Mission PIN gemt.', 'success');
showNotification('Mission PIN gemt', 'success');
} catch (error) {
setMissionPinFeedback(error.message || 'Kunne ikke gemme PIN', 'error');
showNotification('Kunne ikke gemme Mission PIN', 'error');
} finally {
btn.disabled = false;
btn.innerHTML = original;
}
}
function updateTelefoniActionPreview() { function updateTelefoniActionPreview() {
const previewEl = document.getElementById('telefoniActionPreview'); const previewEl = document.getElementById('telefoniActionPreview');
const template = (document.getElementById('telefoniActionTemplate')?.value || '').trim(); const template = (document.getElementById('telefoniActionTemplate')?.value || '').trim();
@ -1705,6 +1985,7 @@ async function loadSettings() {
allSettings = await response.json(); allSettings = await response.json();
displaySettingsByCategory(); displaySettingsByCategory();
renderTelefoniSettings(); renderTelefoniSettings();
renderMissionSettings();
await loadCaseTypesSetting(); await loadCaseTypesSetting();
await loadCaseStatusesSetting(); await loadCaseStatusesSetting();
await loadTagsManagement(); await loadTagsManagement();
@ -3326,6 +3607,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
loadTagsManagement(); loadTagsManagement();
} else if (tab === 'telefoni') { } else if (tab === 'telefoni') {
renderTelefoniSettings(); renderTelefoniSettings();
} else if (tab === 'mission') {
renderMissionSettings();
} else if (tab === 'ai-prompts') { } else if (tab === 'ai-prompts') {
loadAIPrompts(); loadAIPrompts();
} else if (tab === 'modules') { } else if (tab === 'modules') {
@ -4270,6 +4553,15 @@ document.addEventListener('DOMContentLoaded', () => {
[yealinkBase, yealinkToken].forEach(el => { [yealinkBase, yealinkToken].forEach(el => {
if (el) el.addEventListener('input', buildYealinkActionUrls); if (el) el.addEventListener('input', buildYealinkActionUrls);
}); });
const saveMissionCameraBtn = document.getElementById('saveMissionCameraSettingsBtn');
if (saveMissionCameraBtn) saveMissionCameraBtn.addEventListener('click', saveMissionCameraSettings);
const copyMissionWebhookBtn = document.getElementById('copyMissionWebhookBtn');
if (copyMissionWebhookBtn) copyMissionWebhookBtn.addEventListener('click', copyMissionWebhookUrl);
const diagnoseMissionCameraBtn = document.getElementById('diagnoseMissionCameraBtn');
if (diagnoseMissionCameraBtn) diagnoseMissionCameraBtn.addEventListener('click', diagnoseMissionCamera);
const saveMissionPinBtn = document.getElementById('saveMissionPinSettingsBtn');
if (saveMissionPinBtn) saveMissionPinBtn.addEventListener('click', saveMissionPinSettings);
}); });
</script> </script>

37
main.py
View File

@ -39,6 +39,19 @@ def _users_column_exists(column_name: str) -> bool:
_users_column_cache[column_name] = exists _users_column_cache[column_name] = exists
return exists return exists
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 get_version(): def get_version():
"""Read version from VERSION file""" """Read version from VERSION file"""
try: try:
@ -236,16 +249,23 @@ app.add_middleware(
@app.middleware("http") @app.middleware("http")
async def auth_middleware(request: Request, call_next): async def auth_middleware(request: Request, call_next):
path = request.url.path path = request.url.path
mission_path = path.startswith("/dashboard/mission-control") or path.startswith("/api/v1/mission/")
mission_pin = _get_mission_access_pin() if mission_path else ""
public_paths = { public_paths = {
"/health", "/health",
"/login", "/login",
"/api/v1/auth/login" "/api/v1/auth/login",
"/mission/pin",
"/mission/pin/verify",
"/mission/pin/logout",
} }
public_prefixes = { public_prefixes = {
"/api/v1/mission/webhook/telefoni/", "/api/v1/mission/webhook/telefoni/",
"/api/v1/mission/webhook/uptime", "/api/v1/mission/webhook/uptime",
"/api/v1/mission/webhook/camera/",
"/api/v1/mission/webhook/environment/",
} }
# Yealink Action URL callbacks (secured inside telefoni module by token/IP) # Yealink Action URL callbacks (secured inside telefoni module by token/IP)
@ -264,12 +284,16 @@ async def auth_middleware(request: Request, call_next):
if ( if (
path in public_paths path in public_paths
or path.startswith("/mission/pin")
or any(path.startswith(prefix) for prefix in public_prefixes) or any(path.startswith(prefix) for prefix in public_prefixes)
or path.startswith("/static") or path.startswith("/static")
or path.startswith("/docs") or path.startswith("/docs")
): ):
return await call_next(request) return await call_next(request)
if mission_path and _has_valid_mission_pin_token(request):
return await call_next(request)
token = None token = None
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if auth_header and auth_header.lower().startswith("bearer "): if auth_header and auth_header.lower().startswith("bearer "):
@ -279,6 +303,17 @@ async def auth_middleware(request: Request, call_next):
payload = AuthService.verify_token(token) if token else None payload = AuthService.verify_token(token) if token else None
if not payload: if not payload:
if mission_path:
if path.startswith("/api"):
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=401,
content={
"detail": "Mission PIN required" if mission_pin else "Mission PIN not configured"
}
)
return RedirectResponse(url="/mission/pin", status_code=302)
if path.startswith("/api"): if path.startswith("/api"):
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
return JSONResponse( return JSONResponse(

View File

@ -0,0 +1,8 @@
-- Mission Control: single camera feed settings
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
('mission_camera_enabled', 'false', 'mission', 'Enable one camera feed in Mission Control', 'boolean', true),
('mission_camera_name', 'Mission Kamera', 'mission', 'Display name for Mission Control camera feed', 'string', true),
('mission_camera_feed_url', '', 'mission', 'Feed URL for Mission Control camera', 'string', true)
ON CONFLICT (key) DO NOTHING;

View File

@ -18,3 +18,5 @@ msal==1.31.0
paramiko==3.5.0 paramiko==3.5.0
APScheduler==3.10.4 APScheduler==3.10.4
pdfplumber==0.11.4 pdfplumber==0.11.4
av==13.1.0
Pillow==11.0.0