Release v2.2.67: mission touch UX, camera/webhook, env temperature feed
This commit is contained in:
parent
daf2f29471
commit
43fd651723
@ -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),
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@ -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'<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)
|
||||
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
@ -1,5 +1,5 @@
|
||||
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 pydantic import BaseModel
|
||||
from datetime import date, datetime
|
||||
@ -76,7 +76,7 @@ async def get_features(version: Optional[str] = None, status: Optional[str] = No
|
||||
params.append(status)
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
@ -86,7 +86,7 @@ async def get_feature(feature_id: int):
|
||||
result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,))
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Feature not found")
|
||||
return result
|
||||
return result[0]
|
||||
|
||||
|
||||
@router.post("/features", response_model=Feature)
|
||||
@ -151,7 +151,7 @@ async def get_ideas(category: Optional[str] = None):
|
||||
params.append(category)
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
@ -163,7 +163,7 @@ async def create_idea(idea: IdeaCreate):
|
||||
VALUES (%s, %s, %s)
|
||||
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}")
|
||||
return result
|
||||
@ -209,7 +209,7 @@ async def get_workflows(category: Optional[str] = None):
|
||||
params.append(category)
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
@ -219,7 +219,7 @@ async def get_workflow(workflow_id: int):
|
||||
result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,))
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
return result
|
||||
return result[0]
|
||||
|
||||
|
||||
@router.post("/workflows", response_model=Workflow)
|
||||
|
||||
@ -174,6 +174,18 @@ class LinkEmailToSagRequest(BaseModel):
|
||||
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")
|
||||
async def get_sag_assignment_options():
|
||||
"""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))
|
||||
|
||||
|
||||
@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")
|
||||
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."""
|
||||
@ -330,7 +368,7 @@ async def list_emails(
|
||||
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):
|
||||
"""Get email detail by ID"""
|
||||
try:
|
||||
@ -443,7 +481,7 @@ async def download_attachment(email_id: int, attachment_id: int):
|
||||
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):
|
||||
"""Update email (archive, mark as read, etc)"""
|
||||
try:
|
||||
@ -1260,7 +1298,7 @@ JSON:"""
|
||||
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):
|
||||
"""Soft delete email"""
|
||||
try:
|
||||
@ -1659,7 +1697,7 @@ async def update_classification(email_id: int, data: ClassificationUpdate):
|
||||
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):
|
||||
"""Soft delete email"""
|
||||
try:
|
||||
|
||||
@ -1498,11 +1498,20 @@ let emailSearchTimeout = null;
|
||||
let autoRefreshInterval = null;
|
||||
let sagAssignmentOptions = { users: [], groups: [] };
|
||||
let customerSearchHideTimeout = null;
|
||||
let pendingOpenEmailId = null;
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('📧 Email UI: DOMContentLoaded fired');
|
||||
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();
|
||||
loadStats();
|
||||
@ -1649,6 +1658,18 @@ async function loadEmails(searchQuery = '') {
|
||||
console.log('Loaded emails:', emails.length, 'items');
|
||||
|
||||
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) {
|
||||
console.error('Failed to load emails:', error);
|
||||
showError('Kunne ikke indlæse emails: ' + error.message);
|
||||
|
||||
@ -17,6 +17,7 @@ from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCr
|
||||
from app.core.config import settings
|
||||
from app.services.email_service import EmailService
|
||||
from app.services.case_analysis_service import CaseAnalysisService
|
||||
from app.services.ollama_service import ollama_service
|
||||
|
||||
try:
|
||||
import extract_msg
|
||||
@ -166,6 +167,18 @@ class QuickCreateRequest(BaseModel):
|
||||
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):
|
||||
to: List[str]
|
||||
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)
|
||||
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
|
||||
# ============================================================================
|
||||
@ -510,7 +567,7 @@ async def create_sag(data: dict):
|
||||
logger.error("❌ Error creating case: %s", e)
|
||||
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):
|
||||
"""Get a specific case."""
|
||||
try:
|
||||
@ -741,7 +798,7 @@ async def delete_todo_step(step_id: int):
|
||||
logger.error("❌ Error deleting todo step: %s", e)
|
||||
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):
|
||||
"""Update a case."""
|
||||
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)
|
||||
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):
|
||||
"""Soft-delete a case."""
|
||||
try:
|
||||
|
||||
@ -37,14 +37,18 @@ def _fetch_assignment_users():
|
||||
|
||||
|
||||
def _fetch_assignment_groups():
|
||||
return execute_query(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM groups
|
||||
ORDER BY name
|
||||
""",
|
||||
()
|
||||
) or []
|
||||
try:
|
||||
return execute_query(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM groups
|
||||
ORDER BY name
|
||||
""",
|
||||
()
|
||||
) 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]:
|
||||
@ -179,7 +183,39 @@ async def sager_liste(
|
||||
params.append(assigned_group_id_int)
|
||||
|
||||
query += " ORDER BY s.created_at DESC"
|
||||
sager = execute_query(query, tuple(params))
|
||||
|
||||
try:
|
||||
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
|
||||
relations_query = """
|
||||
@ -191,7 +227,11 @@ async def sager_liste(
|
||||
FROM sag_relationer sr
|
||||
WHERE sr.deleted_at IS NULL
|
||||
"""
|
||||
all_relations = execute_query(relations_query, ())
|
||||
try:
|
||||
all_relations = execute_query(relations_query, ())
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ Could not load case relations: %s", e)
|
||||
all_relations = []
|
||||
child_ids = set()
|
||||
|
||||
# Build relations map: {sag_id: [list of related sag_ids]}
|
||||
|
||||
@ -2188,6 +2188,9 @@
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="cancelBeskrivelsEdit()">
|
||||
<i class="bi bi-x me-1"></i>Annuller
|
||||
</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()">
|
||||
<i class="bi bi-check2 me-1"></i>Gem
|
||||
</button>
|
||||
@ -4720,7 +4723,12 @@
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@ -4736,6 +4744,30 @@
|
||||
</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 -->
|
||||
<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 -->
|
||||
@ -8583,6 +8615,200 @@
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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() {
|
||||
const primaryContact = document.querySelector('.contact-row[data-is-primary="true"][data-email]');
|
||||
if (primaryContact?.dataset?.email) {
|
||||
@ -9289,6 +9515,21 @@
|
||||
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');
|
||||
if (caseEmailComposeModal) {
|
||||
caseEmailComposeModal.addEventListener('show.bs.modal', () => {
|
||||
@ -10781,6 +11022,86 @@
|
||||
const SAG_ID = {{ case.id }};
|
||||
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 () {
|
||||
const current = document.getElementById('beskrivelse-text').innerText.trim();
|
||||
document.getElementById('beskrivelse-textarea').value = current;
|
||||
|
||||
@ -139,6 +139,126 @@ Output: {
|
||||
}],
|
||||
"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 rå 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:
|
||||
"""
|
||||
|
||||
@ -220,6 +220,29 @@ async def update_setting(key: str, setting: SettingUpdate):
|
||||
True,
|
||||
)
|
||||
)
|
||||
|
||||
# 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:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
@ -473,6 +496,42 @@ Output: [Fakturastekst]""",
|
||||
"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 rå 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": {
|
||||
"name": "📚 Vidensbank Generator (Solution to Article)",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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!",
|
||||
|
||||
@ -113,6 +113,9 @@
|
||||
<a class="nav-link" href="#modules" data-tab="modules">
|
||||
<i class="bi bi-box-seam me-2"></i>Moduler
|
||||
</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">
|
||||
<i class="bi bi-gear me-2"></i>System
|
||||
</a>
|
||||
@ -1099,6 +1102,75 @@ async def scan_document(file_path: str):
|
||||
</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 -->
|
||||
<div class="tab-pane fade" id="system">
|
||||
<div class="card p-4 mb-4">
|
||||
@ -1539,6 +1611,214 @@ function renderTelefoniSettings() {
|
||||
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() {
|
||||
const previewEl = document.getElementById('telefoniActionPreview');
|
||||
const template = (document.getElementById('telefoniActionTemplate')?.value || '').trim();
|
||||
@ -1705,6 +1985,7 @@ async function loadSettings() {
|
||||
allSettings = await response.json();
|
||||
displaySettingsByCategory();
|
||||
renderTelefoniSettings();
|
||||
renderMissionSettings();
|
||||
await loadCaseTypesSetting();
|
||||
await loadCaseStatusesSetting();
|
||||
await loadTagsManagement();
|
||||
@ -3326,6 +3607,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
||||
loadTagsManagement();
|
||||
} else if (tab === 'telefoni') {
|
||||
renderTelefoniSettings();
|
||||
} else if (tab === 'mission') {
|
||||
renderMissionSettings();
|
||||
} else if (tab === 'ai-prompts') {
|
||||
loadAIPrompts();
|
||||
} else if (tab === 'modules') {
|
||||
@ -4270,6 +4553,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
[yealinkBase, yealinkToken].forEach(el => {
|
||||
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>
|
||||
|
||||
|
||||
37
main.py
37
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(
|
||||
|
||||
8
migrations/149_mission_camera_feed.sql
Normal file
8
migrations/149_mission_camera_feed.sql
Normal 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;
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user