From 43fd65172311a5ce2dd2f2175636b160536b9960 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 25 Mar 2026 13:46:03 +0100 Subject: [PATCH] Release v2.2.67: mission touch UX, camera/webhook, env temperature feed --- app/dashboard/backend/mission_router.py | 404 +++++- app/dashboard/backend/mission_service.py | 143 ++ app/dashboard/backend/views.py | 122 ++ app/dashboard/frontend/mission_control.html | 1438 +++++++++++++++---- app/devportal/backend/router.py | 14 +- app/emails/backend/router.py | 46 +- app/emails/frontend/emails.html | 21 + app/modules/sag/backend/router.py | 63 +- app/modules/sag/frontend/views.py | 60 +- app/modules/sag/templates/detail.html | 323 ++++- app/services/ollama_service.py | 120 ++ app/settings/backend/router.py | 60 + app/settings/frontend/settings.html | 292 ++++ main.py | 37 +- migrations/149_mission_camera_feed.sql | 8 + requirements.txt | 2 + 16 files changed, 2842 insertions(+), 311 deletions(-) create mode 100644 migrations/149_mission_camera_feed.sql diff --git a/app/dashboard/backend/mission_router.py b/app/dashboard/backend/mission_router.py index f0ce6ec..81e9c1e 100644 --- a/app/dashboard/backend/mission_router.py +++ b/app/dashboard/backend/mission_router.py @@ -1,9 +1,13 @@ import json import logging +import io +import time from datetime import datetime from typing import Any, Dict, Optional +from urllib.parse import urlparse from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from app.core.auth_service import AuthService @@ -32,6 +36,35 @@ class MissionUptimeWebhook(BaseModel): 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]: for name in names: 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") async def get_mission_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") async def mission_ws(websocket: WebSocket): token = websocket.query_params.get("token") auth_header = (websocket.headers.get("authorization") or "").strip() if not token and auth_header.lower().startswith("bearer "): 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 + 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: await websocket.close(code=1008) return @@ -453,3 +730,126 @@ async def mission_uptime_webhook(payload: MissionUptimeWebhook, request: Request await mission_ws_manager.broadcast("live_feed_event", event_row) 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), + } diff --git a/app/dashboard/backend/mission_service.py b/app/dashboard/backend/mission_service.py index 2941f56..b0569db 100644 --- a/app/dashboard/backend/mission_service.py +++ b/app/dashboard/backend/mission_service.py @@ -9,6 +9,8 @@ logger = logging.getLogger(__name__) class MissionService: + MISSION_CASE_TYPES = {"ticket", "opgave", "ordre", "projekt", "service"} + @staticmethod def _safe(label: str, func, default): try: @@ -240,6 +242,124 @@ class MissionService: ) 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 def get_state() -> Dict[str, Any]: kpis_default = { @@ -256,6 +376,9 @@ class MissionService: "employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []), "active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []), "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": { "display_queues": MissionService._safe("config.display_queues", lambda: MissionService.parse_json_setting("mission_display_queues", []), []), "sound_enabled": MissionService._safe( @@ -286,5 +409,25 @@ class MissionService: 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, + ), }, } diff --git a/app/dashboard/backend/views.py b/app/dashboard/backend/views.py index 48cf509..7924dd6 100644 --- a/app/dashboard/backend/views.py +++ b/app/dashboard/backend/views.py @@ -1,9 +1,13 @@ import logging +from datetime import datetime, timedelta from fastapi import APIRouter, Request, Form from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, RedirectResponse from app.core.database import execute_query, execute_query_single +from app.core.config import settings +from app.core.auth_service import AuthService +import jwt router = APIRouter() templates = Jinja2Templates(directory="app") @@ -74,6 +78,119 @@ def _is_sales_group(group_names) -> bool: for group in (group_names or []) ) + +def _get_mission_access_pin() -> str: + row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("mission_access_pin",)) + db_pin = str((row or {}).get("value") or "").strip() + env_pin = str(getattr(settings, "MISSION_ACCESS_PIN", "") or "").strip() + return db_pin or env_pin + + +def _has_valid_mission_pin_token(request: Request) -> bool: + token = request.cookies.get("mission_pin_token") + payload = AuthService.verify_token(token) if token else None + return bool(payload and payload.get("scope") == "mission_pin") + + +def _create_mission_pin_token() -> str: + payload = { + "sub": "0", + "username": "mission-kiosk", + "shadow_admin": True, + "scope": "mission_pin", + "iat": datetime.utcnow(), + "exp": datetime.utcnow() + timedelta(hours=12), + } + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256") + + +def _sanitize_mission_next(value: str) -> str: + if not value: + return "/dashboard/mission-control" + candidate = value.strip() + if candidate == "/dashboard/mission-control": + return candidate + if candidate.startswith("/api/v1/mission/"): + return candidate + return "/dashboard/mission-control" + + +def _render_mission_pin_page(error_text: str = "", next_path: str = "/dashboard/mission-control") -> HTMLResponse: + safe_next = _sanitize_mission_next(next_path) + error_html = f'
{error_text}
' if error_text else "" + html = f""" + + + + + + Mission Control PIN + + + +
+

Mission Control

+

Indtast PIN-kode for at fortsætte.

+ {error_html} +
+ + + +
+
+ + + """ + return HTMLResponse(content=html) + + +@router.get("/mission/pin", response_class=HTMLResponse) +async def mission_pin_page(request: Request, next: str = "/dashboard/mission-control"): + if _has_valid_mission_pin_token(request): + return RedirectResponse(url=_sanitize_mission_next(next), status_code=302) + return _render_mission_pin_page(next_path=next) + + +@router.get("/mission/pin/", response_class=HTMLResponse) +async def mission_pin_page_trailing_slash(request: Request, next: str = "/dashboard/mission-control"): + return await mission_pin_page(request, next) + + +@router.post("/mission/pin/verify") +async def mission_pin_verify(pin: str = Form(...), next: str = Form("/dashboard/mission-control")): + configured_pin = _get_mission_access_pin() + if not configured_pin: + return _render_mission_pin_page("PIN er ikke konfigureret på serveren.", next) + + if pin.strip() != configured_pin: + return _render_mission_pin_page("Forkert PIN-kode.", next) + + token = _create_mission_pin_token() + redirect_target = _sanitize_mission_next(next) + response = RedirectResponse(url=redirect_target, status_code=302) + response.set_cookie( + key="mission_pin_token", + value=token, + httponly=True, + samesite="Lax", + max_age=60 * 60 * 12, + ) + return response + + +@router.post("/mission/pin/logout") +async def mission_pin_logout(): + response = RedirectResponse(url="/mission/pin", status_code=302) + response.delete_cookie("mission_pin_token") + return response + @router.get("/", response_class=HTMLResponse) async def dashboard(request: Request): """ @@ -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) + diff --git a/app/dashboard/frontend/mission_control.html b/app/dashboard/frontend/mission_control.html index dfa8a47..c893589 100644 --- a/app/dashboard/frontend/mission_control.html +++ b/app/dashboard/frontend/mission_control.html @@ -5,191 +5,72 @@ {% block extra_css %} {% endblock %} {% block content %} -
-
-
-
Ingen aktive driftsalarmer
-
- - - Forbinder... +
+
+
+
+
Mission Control
+
Forbinder...
+
+
Auto reset: 10s inaktivitet
+
+ +
+ + +
+ +
+ + + + +
+
+ +
+
+
+ +
+
+
+
+

Driftsstatus

+
Ingen aktive driftsalarmer
+ +
+ +
+
Ingen aktive opkald
+
Mission overvager opkald og opdaterer live.
+
+ +
+
Temperatur sensorer
+
+
+
+ +
+
+
Kamera (startvisning)
+ Ikke aktiv +
+ +
Feed er ikke aktiveret endnu.
+
+
+
+ +
+
+

Vigtige sager

+
+
Sag
+
Type
+
Status
+
Deadline
+
+
+ + +
+
+ +
+
+
+

Aktive opkald

+
+
+
+

Deadlines pr. medarbejder

+
+
Medarbejder
+
I dag
+
Overskredet
+
+
+
+
+
+
+ +
+
+
+

Kamera spotlight

+ Ikke aktiv +
+
+ Spotlight varighed + + + +
+ +
Feed er ikke aktiveret endnu.
-
-
-

Opgave-overblik

-
-
-
Indgående opkald
-
-
-
-
- -
-

Aktive opkald

-
-
-
- -
-
-

Deadlines pr. medarbejder

-
-
Medarbejder
-
I dag
-
Overskredet
-
-
-
- -
-

Live aktivitetsfeed

-
-
+
+
Live aktivitetsfeed
+
diff --git a/main.py b/main.py index b9110c7..a11b0f9 100644 --- a/main.py +++ b/main.py @@ -39,6 +39,19 @@ def _users_column_exists(column_name: str) -> bool: _users_column_cache[column_name] = 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(): """Read version from VERSION file""" try: @@ -236,16 +249,23 @@ app.add_middleware( @app.middleware("http") async def auth_middleware(request: Request, call_next): 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 = { "/health", "/login", - "/api/v1/auth/login" + "/api/v1/auth/login", + "/mission/pin", + "/mission/pin/verify", + "/mission/pin/logout", } public_prefixes = { "/api/v1/mission/webhook/telefoni/", "/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) @@ -264,12 +284,16 @@ async def auth_middleware(request: Request, call_next): if ( path in public_paths + or path.startswith("/mission/pin") or any(path.startswith(prefix) for prefix in public_prefixes) or path.startswith("/static") or path.startswith("/docs") ): return await call_next(request) + if mission_path and _has_valid_mission_pin_token(request): + return await call_next(request) + token = None auth_header = request.headers.get("Authorization") 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 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"): from fastapi.responses import JSONResponse return JSONResponse( diff --git a/migrations/149_mission_camera_feed.sql b/migrations/149_mission_camera_feed.sql new file mode 100644 index 0000000..4f08a31 --- /dev/null +++ b/migrations/149_mission_camera_feed.sql @@ -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; diff --git a/requirements.txt b/requirements.txt index 1b9d9a1..363702c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,5 @@ msal==1.31.0 paramiko==3.5.0 APScheduler==3.10.4 pdfplumber==0.11.4 +av==13.1.0 +Pillow==11.0.0