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 json
|
||||||
import logging
|
import logging
|
||||||
|
import io
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
@ -32,6 +36,35 @@ class MissionUptimeWebhook(BaseModel):
|
|||||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionCameraConfigUpdate(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
camera_name: Optional[str] = None
|
||||||
|
feed_url: Optional[str] = None
|
||||||
|
spotlight_seconds: Optional[int] = 20
|
||||||
|
|
||||||
|
|
||||||
|
class MissionCameraMotionWebhook(BaseModel):
|
||||||
|
camera_name: Optional[str] = None
|
||||||
|
motion: Optional[bool] = True
|
||||||
|
event_type: Optional[str] = None
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
snapshot_url: Optional[str] = None
|
||||||
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionAccessPinUpdate(BaseModel):
|
||||||
|
pin: str = Field(..., min_length=4, max_length=10)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionTemperatureWebhook(BaseModel):
|
||||||
|
sensor_id: Optional[str] = None
|
||||||
|
sensor_name: Optional[str] = None
|
||||||
|
temperature: float
|
||||||
|
unit: Optional[str] = "°C"
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
def _first_query_param(request: Request, *names: str) -> Optional[str]:
|
def _first_query_param(request: Request, *names: str) -> Optional[str]:
|
||||||
for name in names:
|
for name in names:
|
||||||
value = request.query_params.get(name)
|
value = request.query_params.get(name)
|
||||||
@ -128,21 +161,265 @@ def _normalize_uptime_payload(payload: MissionUptimeWebhook) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_feed_url(candidate: Optional[str]) -> bool:
|
||||||
|
if not candidate:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = urlparse(candidate.strip())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return parsed.scheme in {"http", "https", "rtsp"} and bool(parsed.netloc)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_authenticated_user(request: Request) -> Dict[str, Any]:
|
||||||
|
token = None
|
||||||
|
auth_header = (request.headers.get("authorization") or "").strip()
|
||||||
|
if auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
if not token:
|
||||||
|
token = (request.cookies.get("access_token") or "").strip()
|
||||||
|
|
||||||
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
if not payload or payload.get("scope") == "mission_pin":
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
user_id = payload.get("sub") or payload.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_access_pin(pin: str) -> bool:
|
||||||
|
return pin.isdigit() and 4 <= len(pin) <= 10
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_mjpeg_frames(feed_url: str, target_fps: float = 5.0):
|
||||||
|
"""Transcode camera frames to MJPEG for browser playback."""
|
||||||
|
try:
|
||||||
|
import av
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("❌ PyAV import failed for camera stream: %s", exc)
|
||||||
|
raise HTTPException(status_code=503, detail="PyAV ikke installeret på serveren")
|
||||||
|
|
||||||
|
options = {
|
||||||
|
"rtsp_transport": "tcp",
|
||||||
|
"fflags": "nobuffer",
|
||||||
|
"flags": "low_delay",
|
||||||
|
"stimeout": "5000000",
|
||||||
|
}
|
||||||
|
|
||||||
|
boundary = b"frame"
|
||||||
|
frame_interval = 1.0 / max(1.0, float(target_fps))
|
||||||
|
last_emit = 0.0
|
||||||
|
container = None
|
||||||
|
try:
|
||||||
|
container = av.open(feed_url, options=options)
|
||||||
|
video_stream = next((s for s in container.streams if s.type == "video"), None)
|
||||||
|
if video_stream is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Feed indeholder ingen video stream")
|
||||||
|
|
||||||
|
for frame in container.decode(video=0):
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_emit < frame_interval:
|
||||||
|
continue
|
||||||
|
last_emit = now
|
||||||
|
|
||||||
|
image = frame.to_image()
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
image.save(buffer, format="JPEG", quality=80)
|
||||||
|
jpeg = buffer.getvalue()
|
||||||
|
|
||||||
|
yield (
|
||||||
|
b"--" + boundary + b"\r\n"
|
||||||
|
+ b"Content-Type: image/jpeg\r\n"
|
||||||
|
+ f"Content-Length: {len(jpeg)}\r\n\r\n".encode("ascii")
|
||||||
|
+ jpeg
|
||||||
|
+ b"\r\n"
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("❌ Camera MJPEG stream failed: %s", exc)
|
||||||
|
raise HTTPException(status_code=502, detail="Kunne ikke åbne kamera stream")
|
||||||
|
finally:
|
||||||
|
if container is not None:
|
||||||
|
try:
|
||||||
|
container.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_camera_stream(feed_url: str) -> Dict[str, Any]:
|
||||||
|
"""Attempt opening and decoding one frame to provide actionable diagnostics."""
|
||||||
|
try:
|
||||||
|
import av
|
||||||
|
except Exception:
|
||||||
|
return {"ok": False, "detail": "PyAV ikke installeret på serveren"}
|
||||||
|
|
||||||
|
options = {
|
||||||
|
"rtsp_transport": "tcp",
|
||||||
|
"fflags": "nobuffer",
|
||||||
|
"flags": "low_delay",
|
||||||
|
"stimeout": "5000000",
|
||||||
|
}
|
||||||
|
|
||||||
|
container = None
|
||||||
|
try:
|
||||||
|
container = av.open(feed_url, options=options)
|
||||||
|
video_stream = next((s for s in container.streams if s.type == "video"), None)
|
||||||
|
if video_stream is None:
|
||||||
|
return {"ok": False, "detail": "Feed indeholder ingen video stream"}
|
||||||
|
|
||||||
|
frame_found = False
|
||||||
|
for _ in container.decode(video=0):
|
||||||
|
frame_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not frame_found:
|
||||||
|
return {"ok": False, "detail": "Ingen frames modtaget fra kamera"}
|
||||||
|
|
||||||
|
return {"ok": True, "detail": "Stream OK"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "detail": f"Kamera stream fejl: {exc}"}
|
||||||
|
finally:
|
||||||
|
if container is not None:
|
||||||
|
try:
|
||||||
|
container.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.get("/mission/state")
|
@router.get("/mission/state")
|
||||||
async def get_mission_state():
|
async def get_mission_state():
|
||||||
return MissionService.get_state()
|
return MissionService.get_state()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mission/camera/mjpeg")
|
||||||
|
async def mission_camera_mjpeg_stream(fps: float = Query(5.0, ge=1.0, le=15.0)):
|
||||||
|
feed_url = (MissionService.get_setting_value("mission_camera_feed_url", "") or "").strip()
|
||||||
|
enabled = str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true"
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="Kamera feed er ikke aktiveret")
|
||||||
|
if not feed_url:
|
||||||
|
raise HTTPException(status_code=400, detail="Kamera feed URL mangler")
|
||||||
|
if not _is_valid_feed_url(feed_url):
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldig kamera feed URL")
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_iter_mjpeg_frames(feed_url=feed_url, target_fps=fps),
|
||||||
|
media_type="multipart/x-mixed-replace; boundary=frame",
|
||||||
|
headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mission/camera/status")
|
||||||
|
async def mission_camera_status():
|
||||||
|
feed_url = (MissionService.get_setting_value("mission_camera_feed_url", "") or "").strip()
|
||||||
|
enabled = str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true"
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
return {"ok": False, "detail": "Kamera feed er ikke aktiveret", "enabled": False}
|
||||||
|
if not feed_url:
|
||||||
|
return {"ok": False, "detail": "Kamera feed URL mangler", "enabled": True}
|
||||||
|
if not _is_valid_feed_url(feed_url):
|
||||||
|
return {"ok": False, "detail": "Ugyldig kamera feed URL", "enabled": True}
|
||||||
|
|
||||||
|
probe = _probe_camera_stream(feed_url)
|
||||||
|
return {
|
||||||
|
"ok": bool(probe.get("ok")),
|
||||||
|
"detail": probe.get("detail") or "Ukendt status",
|
||||||
|
"enabled": True,
|
||||||
|
"feed_scheme": feed_url.split(":", 1)[0].lower() if ":" in feed_url else "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mission/camera/config")
|
||||||
|
async def update_mission_camera_config(config: MissionCameraConfigUpdate):
|
||||||
|
feed_url = (config.feed_url or "").strip()
|
||||||
|
camera_name = (config.camera_name or "Mission Kamera").strip() or "Mission Kamera"
|
||||||
|
spotlight_seconds = int(config.spotlight_seconds or 20)
|
||||||
|
spotlight_seconds = max(5, min(spotlight_seconds, 120))
|
||||||
|
|
||||||
|
if feed_url and not _is_valid_feed_url(feed_url):
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldig feed URL. Brug rtsp/http/https")
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES
|
||||||
|
(%s, %s, 'mission', 'Enable one camera feed in Mission Control', 'boolean', true),
|
||||||
|
(%s, %s, 'mission', 'Camera name for Mission Control', 'string', true),
|
||||||
|
(%s, %s, 'mission', 'Camera feed URL for Mission Control', 'string', true),
|
||||||
|
(%s, %s, 'mission', 'Camera spotlight duration in seconds for motion events', 'integer', true)
|
||||||
|
ON CONFLICT (key)
|
||||||
|
DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"mission_camera_enabled",
|
||||||
|
"true" if config.enabled else "false",
|
||||||
|
"mission_camera_name",
|
||||||
|
camera_name,
|
||||||
|
"mission_camera_feed_url",
|
||||||
|
feed_url,
|
||||||
|
"mission_camera_spotlight_seconds",
|
||||||
|
str(spotlight_seconds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"camera": {
|
||||||
|
"enabled": config.enabled,
|
||||||
|
"camera_name": camera_name,
|
||||||
|
"feed_url": feed_url,
|
||||||
|
"spotlight_seconds": spotlight_seconds,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mission/access-pin")
|
||||||
|
async def update_mission_access_pin(request: Request, payload: MissionAccessPinUpdate):
|
||||||
|
_require_authenticated_user(request)
|
||||||
|
|
||||||
|
new_pin = (payload.pin or "").strip()
|
||||||
|
if not _is_valid_access_pin(new_pin):
|
||||||
|
raise HTTPException(status_code=400, detail="PIN skal være 4-10 cifre")
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES (%s, %s, 'mission', 'Access PIN for Mission Control kiosk mode', 'string', false)
|
||||||
|
ON CONFLICT (key)
|
||||||
|
DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
("mission_access_pin", new_pin),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "ok", "message": "Mission PIN opdateret"}
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/mission/ws")
|
@router.websocket("/mission/ws")
|
||||||
async def mission_ws(websocket: WebSocket):
|
async def mission_ws(websocket: WebSocket):
|
||||||
token = websocket.query_params.get("token")
|
token = websocket.query_params.get("token")
|
||||||
auth_header = (websocket.headers.get("authorization") or "").strip()
|
auth_header = (websocket.headers.get("authorization") or "").strip()
|
||||||
if not token and auth_header.lower().startswith("bearer "):
|
if not token and auth_header.lower().startswith("bearer "):
|
||||||
token = auth_header.split(" ", 1)[1].strip()
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
if not token:
|
|
||||||
token = (websocket.cookies.get("access_token") or "").strip() or None
|
|
||||||
|
|
||||||
payload = AuthService.verify_token(token) if token else None
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
if not payload:
|
||||||
|
access_cookie_token = (websocket.cookies.get("access_token") or "").strip() or None
|
||||||
|
payload = AuthService.verify_token(access_cookie_token) if access_cookie_token else None
|
||||||
|
if not payload:
|
||||||
|
mission_pin_cookie_token = (websocket.cookies.get("mission_pin_token") or "").strip() or None
|
||||||
|
payload = AuthService.verify_token(mission_pin_cookie_token) if mission_pin_cookie_token else None
|
||||||
|
|
||||||
if not payload:
|
if not payload:
|
||||||
await websocket.close(code=1008)
|
await websocket.close(code=1008)
|
||||||
return
|
return
|
||||||
@ -453,3 +730,126 @@ async def mission_uptime_webhook(payload: MissionUptimeWebhook, request: Request
|
|||||||
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
||||||
|
|
||||||
return {"status": "ok", "normalized": normalized}
|
return {"status": "ok", "normalized": normalized}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/webhook/camera/motion")
|
||||||
|
async def mission_camera_motion_webhook(
|
||||||
|
payload: MissionCameraMotionWebhook,
|
||||||
|
request: Request,
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
_validate_mission_webhook_token(request, token)
|
||||||
|
|
||||||
|
raw_payload = dict(payload.payload or {})
|
||||||
|
motion_detected = bool(payload.motion)
|
||||||
|
if payload.event_type and str(payload.event_type).strip().lower() in {"no_motion", "idle", "clear"}:
|
||||||
|
motion_detected = False
|
||||||
|
|
||||||
|
camera_name = (payload.camera_name or MissionService.get_setting_value("mission_camera_name", "Mission Kamera") or "Mission Kamera").strip()
|
||||||
|
event_timestamp = payload.timestamp or datetime.utcnow()
|
||||||
|
event_timestamp_iso = event_timestamp.isoformat()
|
||||||
|
snapshot_url = (payload.snapshot_url or "").strip() or None
|
||||||
|
|
||||||
|
await mission_ws_manager.broadcast(
|
||||||
|
"camera_motion",
|
||||||
|
{
|
||||||
|
"camera_name": camera_name,
|
||||||
|
"motion": motion_detected,
|
||||||
|
"timestamp": event_timestamp_iso,
|
||||||
|
"snapshot_url": snapshot_url,
|
||||||
|
"payload": raw_payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"camera_name": camera_name,
|
||||||
|
"motion": motion_detected,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/webhook/environment/temperature")
|
||||||
|
async def mission_environment_temperature_webhook(
|
||||||
|
payload: MissionTemperatureWebhook,
|
||||||
|
request: Request,
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
_validate_mission_webhook_token(request, token)
|
||||||
|
|
||||||
|
sensor_id = (payload.sensor_id or "").strip() or None
|
||||||
|
sensor_name = (payload.sensor_name or "").strip() or sensor_id or "Temperatur"
|
||||||
|
unit = (payload.unit or "°C").strip() or "°C"
|
||||||
|
timestamp = payload.timestamp or datetime.utcnow()
|
||||||
|
|
||||||
|
raw_payload = dict(payload.payload or {})
|
||||||
|
reading = {
|
||||||
|
"sensor_id": sensor_id,
|
||||||
|
"sensor_name": sensor_name,
|
||||||
|
"temperature": float(payload.temperature),
|
||||||
|
"unit": unit,
|
||||||
|
"timestamp": timestamp.isoformat(),
|
||||||
|
"payload": raw_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = MissionService.parse_json_setting("mission_environment_readings", [])
|
||||||
|
if not isinstance(existing, list):
|
||||||
|
existing = []
|
||||||
|
|
||||||
|
merged: list[Dict[str, Any]] = []
|
||||||
|
replaced = False
|
||||||
|
for item in existing:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
item_sensor_id = str(item.get("sensor_id") or "").strip() or None
|
||||||
|
item_sensor_name = str(item.get("sensor_name") or "").strip()
|
||||||
|
|
||||||
|
# Keep one latest entry per sensor when possible.
|
||||||
|
if sensor_id and item_sensor_id == sensor_id and not replaced:
|
||||||
|
merged.append(reading)
|
||||||
|
replaced = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (not sensor_id) and item_sensor_name and item_sensor_name == sensor_name and not replaced:
|
||||||
|
merged.append(reading)
|
||||||
|
replaced = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
merged.append(item)
|
||||||
|
|
||||||
|
if not replaced:
|
||||||
|
merged.insert(0, reading)
|
||||||
|
|
||||||
|
merged = merged[:12]
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES (%s, %s, 'mission', 'Latest environment sensor readings for Mission Control', 'json', true)
|
||||||
|
ON CONFLICT (key)
|
||||||
|
DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
("mission_environment_readings", json.dumps(merged, ensure_ascii=False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
event_row = MissionService.insert_event(
|
||||||
|
event_type="environment_temperature",
|
||||||
|
title=f"Temperatur {sensor_name}: {payload.temperature:.1f}{unit}",
|
||||||
|
severity="info",
|
||||||
|
source="home_assistant",
|
||||||
|
payload=reading,
|
||||||
|
)
|
||||||
|
|
||||||
|
await mission_ws_manager.broadcast(
|
||||||
|
"mission_environment_temperature",
|
||||||
|
{"reading": reading, "environment_readings": merged},
|
||||||
|
)
|
||||||
|
if event_row:
|
||||||
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"reading": reading,
|
||||||
|
"count": len(merged),
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class MissionService:
|
class MissionService:
|
||||||
|
MISSION_CASE_TYPES = {"ticket", "opgave", "ordre", "projekt", "service"}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _safe(label: str, func, default):
|
def _safe(label: str, func, default):
|
||||||
try:
|
try:
|
||||||
@ -240,6 +242,124 @@ class MissionService:
|
|||||||
)
|
)
|
||||||
return rows or []
|
return rows or []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_case_type(template_key: Optional[str]) -> str:
|
||||||
|
key = (template_key or "").strip().lower()
|
||||||
|
if not key:
|
||||||
|
return "opgave"
|
||||||
|
|
||||||
|
if "ticket" in key:
|
||||||
|
return "ticket"
|
||||||
|
if "ordre" in key or "order" in key:
|
||||||
|
return "ordre"
|
||||||
|
if "projekt" in key or "project" in key or "pipeline" in key:
|
||||||
|
return "projekt"
|
||||||
|
if "service" in key or "support" in key:
|
||||||
|
return "service"
|
||||||
|
if "opgave" in key or "task" in key:
|
||||||
|
return "opgave"
|
||||||
|
return "opgave"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_important_cases(limit: int = 80) -> list[Dict[str, Any]]:
|
||||||
|
if not MissionService._table_exists("sag_sager"):
|
||||||
|
logger.warning("Mission table missing: sag_sager (important cases unavailable)")
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.status,
|
||||||
|
s.priority,
|
||||||
|
s.deadline,
|
||||||
|
s.template_key,
|
||||||
|
s.created_at,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) <> 'afsluttet'
|
||||||
|
ORDER BY
|
||||||
|
CASE LOWER(COALESCE(s.priority, ''))
|
||||||
|
WHEN 'kritisk' THEN 5
|
||||||
|
WHEN 'critical' THEN 5
|
||||||
|
WHEN 'høj' THEN 4
|
||||||
|
WHEN 'hoj' THEN 4
|
||||||
|
WHEN 'high' THEN 4
|
||||||
|
WHEN 'medium' THEN 3
|
||||||
|
WHEN 'normal' THEN 2
|
||||||
|
WHEN 'lav' THEN 1
|
||||||
|
WHEN 'low' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END DESC,
|
||||||
|
s.deadline ASC NULLS LAST,
|
||||||
|
s.created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
result: list[Dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
normalized_type = MissionService._normalize_case_type(item.get("template_key"))
|
||||||
|
if normalized_type not in MissionService.MISSION_CASE_TYPES:
|
||||||
|
normalized_type = "opgave"
|
||||||
|
item["case_type"] = normalized_type
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_recent_emails(limit: int = 25) -> list[Dict[str, Any]]:
|
||||||
|
if not MissionService._table_exists("email_messages"):
|
||||||
|
logger.warning("Mission table missing: email_messages (recent emails unavailable)")
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
subject,
|
||||||
|
sender_name,
|
||||||
|
sender_email,
|
||||||
|
classification,
|
||||||
|
status,
|
||||||
|
linked_case_id,
|
||||||
|
received_date
|
||||||
|
FROM email_messages
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
ORDER BY received_date DESC NULLS LAST, created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_environment_readings(limit: int = 12) -> list[Dict[str, Any]]:
|
||||||
|
readings = MissionService.parse_json_setting("mission_environment_readings", [])
|
||||||
|
if not isinstance(readings, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
result: list[Dict[str, Any]] = []
|
||||||
|
for raw in readings:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
item = {
|
||||||
|
"sensor_id": str(raw.get("sensor_id") or "").strip() or None,
|
||||||
|
"sensor_name": str(raw.get("sensor_name") or "").strip() or "Sensor",
|
||||||
|
"temperature": raw.get("temperature"),
|
||||||
|
"unit": str(raw.get("unit") or "°C").strip() or "°C",
|
||||||
|
"timestamp": raw.get("timestamp"),
|
||||||
|
"payload": raw.get("payload") if isinstance(raw.get("payload"), dict) else {},
|
||||||
|
}
|
||||||
|
result.append(item)
|
||||||
|
|
||||||
|
return result[: max(1, int(limit))]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_state() -> Dict[str, Any]:
|
def get_state() -> Dict[str, Any]:
|
||||||
kpis_default = {
|
kpis_default = {
|
||||||
@ -256,6 +376,9 @@ class MissionService:
|
|||||||
"employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []),
|
"employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []),
|
||||||
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
|
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
|
||||||
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
|
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
|
||||||
|
"important_cases": MissionService._safe("important_cases", lambda: MissionService.get_important_cases(80), []),
|
||||||
|
"recent_emails": MissionService._safe("recent_emails", lambda: MissionService.get_recent_emails(25), []),
|
||||||
|
"environment_readings": MissionService._safe("environment_readings", lambda: MissionService.get_environment_readings(12), []),
|
||||||
"config": {
|
"config": {
|
||||||
"display_queues": MissionService._safe("config.display_queues", lambda: MissionService.parse_json_setting("mission_display_queues", []), []),
|
"display_queues": MissionService._safe("config.display_queues", lambda: MissionService.parse_json_setting("mission_display_queues", []), []),
|
||||||
"sound_enabled": MissionService._safe(
|
"sound_enabled": MissionService._safe(
|
||||||
@ -286,5 +409,25 @@ class MissionService:
|
|||||||
lambda: MissionService.get_setting_value("mission_customer_filter", "") or "",
|
lambda: MissionService.get_setting_value("mission_customer_filter", "") or "",
|
||||||
"",
|
"",
|
||||||
),
|
),
|
||||||
|
"camera_enabled": MissionService._safe(
|
||||||
|
"config.camera_enabled",
|
||||||
|
lambda: str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"camera_name": MissionService._safe(
|
||||||
|
"config.camera_name",
|
||||||
|
lambda: MissionService.get_setting_value("mission_camera_name", "Mission Kamera") or "Mission Kamera",
|
||||||
|
"Mission Kamera",
|
||||||
|
),
|
||||||
|
"camera_feed_url": MissionService._safe(
|
||||||
|
"config.camera_feed_url",
|
||||||
|
lambda: MissionService.get_setting_value("mission_camera_feed_url", "") or "",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
"camera_spotlight_seconds": MissionService._safe(
|
||||||
|
"config.camera_spotlight_seconds",
|
||||||
|
lambda: max(5, min(int(MissionService.get_setting_value("mission_camera_spotlight_seconds", "20") or 20), 120)),
|
||||||
|
20,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Form
|
from fastapi import APIRouter, Request, Form
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from app.core.database import execute_query, execute_query_single
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
import jwt
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
@ -74,6 +78,119 @@ def _is_sales_group(group_names) -> bool:
|
|||||||
for group in (group_names or [])
|
for group in (group_names or [])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mission_access_pin() -> str:
|
||||||
|
row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("mission_access_pin",))
|
||||||
|
db_pin = str((row or {}).get("value") or "").strip()
|
||||||
|
env_pin = str(getattr(settings, "MISSION_ACCESS_PIN", "") or "").strip()
|
||||||
|
return db_pin or env_pin
|
||||||
|
|
||||||
|
|
||||||
|
def _has_valid_mission_pin_token(request: Request) -> bool:
|
||||||
|
token = request.cookies.get("mission_pin_token")
|
||||||
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
return bool(payload and payload.get("scope") == "mission_pin")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_mission_pin_token() -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": "0",
|
||||||
|
"username": "mission-kiosk",
|
||||||
|
"shadow_admin": True,
|
||||||
|
"scope": "mission_pin",
|
||||||
|
"iat": datetime.utcnow(),
|
||||||
|
"exp": datetime.utcnow() + timedelta(hours=12),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_mission_next(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return "/dashboard/mission-control"
|
||||||
|
candidate = value.strip()
|
||||||
|
if candidate == "/dashboard/mission-control":
|
||||||
|
return candidate
|
||||||
|
if candidate.startswith("/api/v1/mission/"):
|
||||||
|
return candidate
|
||||||
|
return "/dashboard/mission-control"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_mission_pin_page(error_text: str = "", next_path: str = "/dashboard/mission-control") -> HTMLResponse:
|
||||||
|
safe_next = _sanitize_mission_next(next_path)
|
||||||
|
error_html = f'<div style="margin:0.75rem 0;color:#ffb4b4;">{error_text}</div>' if error_text else ""
|
||||||
|
html = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=\"da\">
|
||||||
|
<head>
|
||||||
|
<meta charset=\"utf-8\" />
|
||||||
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
||||||
|
<title>Mission Control PIN</title>
|
||||||
|
<style>
|
||||||
|
body {{ margin: 0; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; background: #0b1320; color: #e9f1ff; display: grid; place-items: center; min-height: 100vh; }}
|
||||||
|
.card {{ width: min(92vw, 420px); padding: 1.2rem; border: 1px solid #2c3c58; border-radius: 14px; background: #121d2f; }}
|
||||||
|
.title {{ font-size: 1.2rem; font-weight: 700; margin: 0 0 0.5rem 0; }}
|
||||||
|
.hint {{ color: #9fb3d1; font-size: 0.9rem; margin: 0 0 0.9rem 0; }}
|
||||||
|
input {{ width: 100%; box-sizing: border-box; border: 1px solid #2c3c58; border-radius: 10px; background: #0f1a2b; color: #e9f1ff; padding: 0.7rem; font-size: 1rem; }}
|
||||||
|
button {{ width: 100%; margin-top: 0.75rem; border: 1px solid #3b82f6; border-radius: 10px; background: #1f5bb8; color: #fff; font-weight: 600; padding: 0.65rem; cursor: pointer; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class=\"card\">
|
||||||
|
<p class=\"title\">Mission Control</p>
|
||||||
|
<p class=\"hint\">Indtast PIN-kode for at fortsætte.</p>
|
||||||
|
{error_html}
|
||||||
|
<form method=\"post\" action=\"/mission/pin/verify\">
|
||||||
|
<input type=\"hidden\" name=\"next\" value=\"{safe_next}\" />
|
||||||
|
<input type=\"password\" name=\"pin\" inputmode=\"numeric\" autocomplete=\"one-time-code\" placeholder=\"PIN-kode\" required />
|
||||||
|
<button type=\"submit\">Åbn Mission Control</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mission/pin", response_class=HTMLResponse)
|
||||||
|
async def mission_pin_page(request: Request, next: str = "/dashboard/mission-control"):
|
||||||
|
if _has_valid_mission_pin_token(request):
|
||||||
|
return RedirectResponse(url=_sanitize_mission_next(next), status_code=302)
|
||||||
|
return _render_mission_pin_page(next_path=next)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mission/pin/", response_class=HTMLResponse)
|
||||||
|
async def mission_pin_page_trailing_slash(request: Request, next: str = "/dashboard/mission-control"):
|
||||||
|
return await mission_pin_page(request, next)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/pin/verify")
|
||||||
|
async def mission_pin_verify(pin: str = Form(...), next: str = Form("/dashboard/mission-control")):
|
||||||
|
configured_pin = _get_mission_access_pin()
|
||||||
|
if not configured_pin:
|
||||||
|
return _render_mission_pin_page("PIN er ikke konfigureret på serveren.", next)
|
||||||
|
|
||||||
|
if pin.strip() != configured_pin:
|
||||||
|
return _render_mission_pin_page("Forkert PIN-kode.", next)
|
||||||
|
|
||||||
|
token = _create_mission_pin_token()
|
||||||
|
redirect_target = _sanitize_mission_next(next)
|
||||||
|
response = RedirectResponse(url=redirect_target, status_code=302)
|
||||||
|
response.set_cookie(
|
||||||
|
key="mission_pin_token",
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="Lax",
|
||||||
|
max_age=60 * 60 * 12,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/pin/logout")
|
||||||
|
async def mission_pin_logout():
|
||||||
|
response = RedirectResponse(url="/mission/pin", status_code=302)
|
||||||
|
response.delete_cookie("mission_pin_token")
|
||||||
|
return response
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def dashboard(request: Request):
|
async def dashboard(request: Request):
|
||||||
"""
|
"""
|
||||||
@ -368,3 +485,8 @@ async def mission_control_dashboard(request: Request):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/mission-control/", response_class=HTMLResponse)
|
||||||
|
async def mission_control_dashboard_trailing_slash(request: Request):
|
||||||
|
return await mission_control_dashboard(request)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query, execute_query_single
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
@ -76,7 +76,7 @@ async def get_features(version: Optional[str] = None, status: Optional[str] = No
|
|||||||
params.append(status)
|
params.append(status)
|
||||||
|
|
||||||
query += " ORDER BY priority DESC, expected_date ASC"
|
query += " ORDER BY priority DESC, expected_date ASC"
|
||||||
result = execute_query_single(query, tuple(params) if params else None)
|
result = execute_query(query, tuple(params) if params else None)
|
||||||
return result or []
|
return result or []
|
||||||
|
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ async def get_feature(feature_id: int):
|
|||||||
result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,))
|
result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Feature not found")
|
raise HTTPException(status_code=404, detail="Feature not found")
|
||||||
return result
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/features", response_model=Feature)
|
@router.post("/features", response_model=Feature)
|
||||||
@ -151,7 +151,7 @@ async def get_ideas(category: Optional[str] = None):
|
|||||||
params.append(category)
|
params.append(category)
|
||||||
|
|
||||||
query += " ORDER BY votes DESC, created_at DESC"
|
query += " ORDER BY votes DESC, created_at DESC"
|
||||||
result = execute_query_single(query, tuple(params) if params else None)
|
result = execute_query(query, tuple(params) if params else None)
|
||||||
return result or []
|
return result or []
|
||||||
|
|
||||||
|
|
||||||
@ -163,7 +163,7 @@ async def create_idea(idea: IdeaCreate):
|
|||||||
VALUES (%s, %s, %s)
|
VALUES (%s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (idea.title, idea.description, idea.category))
|
result = execute_query_single(query, (idea.title, idea.description, idea.category))
|
||||||
|
|
||||||
logger.info(f"✅ Created idea: {idea.title}")
|
logger.info(f"✅ Created idea: {idea.title}")
|
||||||
return result
|
return result
|
||||||
@ -209,7 +209,7 @@ async def get_workflows(category: Optional[str] = None):
|
|||||||
params.append(category)
|
params.append(category)
|
||||||
|
|
||||||
query += " ORDER BY created_at DESC"
|
query += " ORDER BY created_at DESC"
|
||||||
result = execute_query_single(query, tuple(params) if params else None)
|
result = execute_query(query, tuple(params) if params else None)
|
||||||
return result or []
|
return result or []
|
||||||
|
|
||||||
|
|
||||||
@ -219,7 +219,7 @@ async def get_workflow(workflow_id: int):
|
|||||||
result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,))
|
result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||||
return result
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/workflows", response_model=Workflow)
|
@router.post("/workflows", response_model=Workflow)
|
||||||
|
|||||||
@ -174,6 +174,18 @@ class LinkEmailToSagRequest(BaseModel):
|
|||||||
mark_processed: bool = True
|
mark_processed: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class RewriteEmailTextRequest(BaseModel):
|
||||||
|
text: str
|
||||||
|
context: Optional[str] = "email"
|
||||||
|
|
||||||
|
|
||||||
|
class RewriteEmailTextResponse(BaseModel):
|
||||||
|
rewritten_text: str
|
||||||
|
model: Optional[str] = None
|
||||||
|
endpoint: Optional[str] = None
|
||||||
|
context: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/emails/sag-options")
|
@router.get("/emails/sag-options")
|
||||||
async def get_sag_assignment_options():
|
async def get_sag_assignment_options():
|
||||||
"""Return users and groups for SAG assignment controls in email UI."""
|
"""Return users and groups for SAG assignment controls in email UI."""
|
||||||
@ -243,6 +255,32 @@ async def search_customers(q: str = Query(..., min_length=1), limit: int = Query
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/emails/rewrite-text", response_model=RewriteEmailTextResponse)
|
||||||
|
async def rewrite_email_text(request: RewriteEmailTextRequest):
|
||||||
|
"""Rewrite email/case text via Ollama using the text_rewrite prompt."""
|
||||||
|
try:
|
||||||
|
input_text = (request.text or "").strip()
|
||||||
|
if not input_text:
|
||||||
|
raise HTTPException(status_code=400, detail="text is required")
|
||||||
|
|
||||||
|
result = await ollama_service.rewrite_text(input_text, request.context or "email")
|
||||||
|
if not result or result.get("error"):
|
||||||
|
detail = (result or {}).get("error") or "Could not rewrite text"
|
||||||
|
raise HTTPException(status_code=502, detail=detail)
|
||||||
|
|
||||||
|
return RewriteEmailTextResponse(
|
||||||
|
rewritten_text=result.get("rewritten_text", ""),
|
||||||
|
model=result.get("model"),
|
||||||
|
endpoint=result.get("endpoint"),
|
||||||
|
context=result.get("context"),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error rewriting email text: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to rewrite text")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/emails/search-sager")
|
@router.get("/emails/search-sager")
|
||||||
async def search_sager(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)):
|
async def search_sager(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)):
|
||||||
"""Autocomplete SAG cases for linking emails to existing cases."""
|
"""Autocomplete SAG cases for linking emails to existing cases."""
|
||||||
@ -330,7 +368,7 @@ async def list_emails(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/emails/{email_id}", response_model=EmailDetail)
|
@router.get("/emails/{email_id:int}", response_model=EmailDetail)
|
||||||
async def get_email(email_id: int):
|
async def get_email(email_id: int):
|
||||||
"""Get email detail by ID"""
|
"""Get email detail by ID"""
|
||||||
try:
|
try:
|
||||||
@ -443,7 +481,7 @@ async def download_attachment(email_id: int, attachment_id: int):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/emails/{email_id}")
|
@router.put("/emails/{email_id:int}")
|
||||||
async def update_email(email_id: int, status: Optional[str] = None):
|
async def update_email(email_id: int, status: Optional[str] = None):
|
||||||
"""Update email (archive, mark as read, etc)"""
|
"""Update email (archive, mark as read, etc)"""
|
||||||
try:
|
try:
|
||||||
@ -1260,7 +1298,7 @@ JSON:"""
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/emails/{email_id}")
|
@router.delete("/emails/{email_id:int}")
|
||||||
async def delete_email(email_id: int):
|
async def delete_email(email_id: int):
|
||||||
"""Soft delete email"""
|
"""Soft delete email"""
|
||||||
try:
|
try:
|
||||||
@ -1659,7 +1697,7 @@ async def update_classification(email_id: int, data: ClassificationUpdate):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/emails/{email_id}")
|
@router.delete("/emails/{email_id:int}")
|
||||||
async def delete_email(email_id: int):
|
async def delete_email(email_id: int):
|
||||||
"""Soft delete email"""
|
"""Soft delete email"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1498,12 +1498,21 @@ let emailSearchTimeout = null;
|
|||||||
let autoRefreshInterval = null;
|
let autoRefreshInterval = null;
|
||||||
let sagAssignmentOptions = { users: [], groups: [] };
|
let sagAssignmentOptions = { users: [], groups: [] };
|
||||||
let customerSearchHideTimeout = null;
|
let customerSearchHideTimeout = null;
|
||||||
|
let pendingOpenEmailId = null;
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
console.log('📧 Email UI: DOMContentLoaded fired');
|
console.log('📧 Email UI: DOMContentLoaded fired');
|
||||||
console.log('Email list element:', document.getElementById('emailListBody'));
|
console.log('Email list element:', document.getElementById('emailListBody'));
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const openEmailRaw = params.get('open') || params.get('email_id');
|
||||||
|
const openEmailId = Number(openEmailRaw || 0);
|
||||||
|
if (Number.isFinite(openEmailId) && openEmailId > 0) {
|
||||||
|
pendingOpenEmailId = openEmailId;
|
||||||
|
currentFilter = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
loadEmails();
|
loadEmails();
|
||||||
loadStats();
|
loadStats();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
@ -1649,6 +1658,18 @@ async function loadEmails(searchQuery = '') {
|
|||||||
console.log('Loaded emails:', emails.length, 'items');
|
console.log('Loaded emails:', emails.length, 'items');
|
||||||
|
|
||||||
renderEmailList(emails);
|
renderEmailList(emails);
|
||||||
|
|
||||||
|
if (pendingOpenEmailId) {
|
||||||
|
const emailToOpen = pendingOpenEmailId;
|
||||||
|
pendingOpenEmailId = null;
|
||||||
|
|
||||||
|
if (emails.some((email) => Number(email.id) === Number(emailToOpen))) {
|
||||||
|
await selectEmail(emailToOpen);
|
||||||
|
} else {
|
||||||
|
currentEmailId = emailToOpen;
|
||||||
|
await loadEmailDetail(emailToOpen);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load emails:', error);
|
console.error('Failed to load emails:', error);
|
||||||
showError('Kunne ikke indlæse emails: ' + error.message);
|
showError('Kunne ikke indlæse emails: ' + error.message);
|
||||||
|
|||||||
@ -17,6 +17,7 @@ from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCr
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.email_service import EmailService
|
from app.services.email_service import EmailService
|
||||||
from app.services.case_analysis_service import CaseAnalysisService
|
from app.services.case_analysis_service import CaseAnalysisService
|
||||||
|
from app.services.ollama_service import ollama_service
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import extract_msg
|
import extract_msg
|
||||||
@ -166,6 +167,18 @@ class QuickCreateRequest(BaseModel):
|
|||||||
user_id: int
|
user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class RewriteTextRequest(BaseModel):
|
||||||
|
text: str = Field(..., min_length=1, max_length=10000)
|
||||||
|
context: Optional[str] = Field(default="case")
|
||||||
|
|
||||||
|
|
||||||
|
class RewriteTextResponse(BaseModel):
|
||||||
|
rewritten_text: str
|
||||||
|
model: Optional[str] = None
|
||||||
|
endpoint: Optional[str] = None
|
||||||
|
context: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SagSendEmailRequest(BaseModel):
|
class SagSendEmailRequest(BaseModel):
|
||||||
to: List[str]
|
to: List[str]
|
||||||
subject: str = Field(..., min_length=1, max_length=998)
|
subject: str = Field(..., min_length=1, max_length=998)
|
||||||
@ -335,6 +348,50 @@ async def analyze_quick_create(request: QuickCreateRequest):
|
|||||||
logger.error(f"❌ QuickCreate analysis failed: {str(e)}", exc_info=True)
|
logger.error(f"❌ QuickCreate analysis failed: {str(e)}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sag/rewrite-text", response_model=RewriteTextResponse)
|
||||||
|
async def rewrite_sag_text(request: RewriteTextRequest):
|
||||||
|
"""Rewrite case/email text using Ollama with configurable prompt."""
|
||||||
|
try:
|
||||||
|
result = await ollama_service.rewrite_text(request.text, request.context or "case")
|
||||||
|
if not result or result.get("error"):
|
||||||
|
detail = (result or {}).get("error") or "Could not rewrite text"
|
||||||
|
raise HTTPException(status_code=502, detail=detail)
|
||||||
|
|
||||||
|
return RewriteTextResponse(
|
||||||
|
rewritten_text=result.get("rewritten_text", ""),
|
||||||
|
model=result.get("model"),
|
||||||
|
endpoint=result.get("endpoint"),
|
||||||
|
context=result.get("context"),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Rewrite text failed: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to rewrite text")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rewrite-text", response_model=RewriteTextResponse)
|
||||||
|
async def rewrite_text_global(request: RewriteTextRequest):
|
||||||
|
"""Global rewrite alias used by UI flows that should avoid feature-specific route conflicts."""
|
||||||
|
try:
|
||||||
|
result = await ollama_service.rewrite_text(request.text, request.context or "case")
|
||||||
|
if not result or result.get("error"):
|
||||||
|
detail = (result or {}).get("error") or "Could not rewrite text"
|
||||||
|
raise HTTPException(status_code=502, detail=detail)
|
||||||
|
|
||||||
|
return RewriteTextResponse(
|
||||||
|
rewritten_text=result.get("rewritten_text", ""),
|
||||||
|
model=result.get("model"),
|
||||||
|
endpoint=result.get("endpoint"),
|
||||||
|
context=result.get("context"),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Global rewrite text failed: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to rewrite text")
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SAGER - CRUD Operations
|
# SAGER - CRUD Operations
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -510,7 +567,7 @@ async def create_sag(data: dict):
|
|||||||
logger.error("❌ Error creating case: %s", e)
|
logger.error("❌ Error creating case: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||||
|
|
||||||
@router.get("/sag/{sag_id}")
|
@router.get("/sag/{sag_id:int}")
|
||||||
async def get_sag(sag_id: int):
|
async def get_sag(sag_id: int):
|
||||||
"""Get a specific case."""
|
"""Get a specific case."""
|
||||||
try:
|
try:
|
||||||
@ -741,7 +798,7 @@ async def delete_todo_step(step_id: int):
|
|||||||
logger.error("❌ Error deleting todo step: %s", e)
|
logger.error("❌ Error deleting todo step: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to delete todo step")
|
raise HTTPException(status_code=500, detail="Failed to delete todo step")
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id}")
|
@router.patch("/sag/{sag_id:int}")
|
||||||
async def update_sag(sag_id: int, updates: dict):
|
async def update_sag(sag_id: int, updates: dict):
|
||||||
"""Update a case."""
|
"""Update a case."""
|
||||||
try:
|
try:
|
||||||
@ -958,7 +1015,7 @@ async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
|
|||||||
logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e)
|
logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to update pipeline")
|
raise HTTPException(status_code=500, detail="Failed to update pipeline")
|
||||||
|
|
||||||
@router.delete("/sag/{sag_id}")
|
@router.delete("/sag/{sag_id:int}")
|
||||||
async def delete_sag(sag_id: int):
|
async def delete_sag(sag_id: int):
|
||||||
"""Soft-delete a case."""
|
"""Soft-delete a case."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -37,6 +37,7 @@ def _fetch_assignment_users():
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_assignment_groups():
|
def _fetch_assignment_groups():
|
||||||
|
try:
|
||||||
return execute_query(
|
return execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT id, name
|
SELECT id, name
|
||||||
@ -45,6 +46,9 @@ def _fetch_assignment_groups():
|
|||||||
""",
|
""",
|
||||||
()
|
()
|
||||||
) or []
|
) or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("⚠️ Could not load assignment groups (groups table missing?): %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
|
def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
|
||||||
@ -179,7 +183,39 @@ async def sager_liste(
|
|||||||
params.append(assigned_group_id_int)
|
params.append(assigned_group_id_int)
|
||||||
|
|
||||||
query += " ORDER BY s.created_at DESC"
|
query += " ORDER BY s.created_at DESC"
|
||||||
|
|
||||||
|
try:
|
||||||
sager = execute_query(query, tuple(params))
|
sager = execute_query(query, tuple(params))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("⚠️ Advanced SAG list query failed, using compatibility fallback: %s", e)
|
||||||
|
|
||||||
|
fallback_query = """
|
||||||
|
SELECT s.*,
|
||||||
|
c.name as customer_name,
|
||||||
|
'' as kontakt_navn,
|
||||||
|
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||||
|
NULL::text AS assigned_group_name,
|
||||||
|
NULL::text AS next_todo_title,
|
||||||
|
NULL::timestamp AS next_todo_due_date
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
fallback_params = []
|
||||||
|
|
||||||
|
if status:
|
||||||
|
fallback_query += " AND s.status = %s"
|
||||||
|
fallback_params.append(status)
|
||||||
|
if customer_id_int:
|
||||||
|
fallback_query += " AND s.customer_id = %s"
|
||||||
|
fallback_params.append(customer_id_int)
|
||||||
|
if ansvarlig_bruger_id_int:
|
||||||
|
fallback_query += " AND s.ansvarlig_bruger_id = %s"
|
||||||
|
fallback_params.append(ansvarlig_bruger_id_int)
|
||||||
|
|
||||||
|
fallback_query += " ORDER BY s.created_at DESC"
|
||||||
|
sager = execute_query(fallback_query, tuple(fallback_params)) or []
|
||||||
|
|
||||||
# Fetch relations for all cases
|
# Fetch relations for all cases
|
||||||
relations_query = """
|
relations_query = """
|
||||||
@ -191,7 +227,11 @@ async def sager_liste(
|
|||||||
FROM sag_relationer sr
|
FROM sag_relationer sr
|
||||||
WHERE sr.deleted_at IS NULL
|
WHERE sr.deleted_at IS NULL
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
all_relations = execute_query(relations_query, ())
|
all_relations = execute_query(relations_query, ())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("⚠️ Could not load case relations: %s", e)
|
||||||
|
all_relations = []
|
||||||
child_ids = set()
|
child_ids = set()
|
||||||
|
|
||||||
# Build relations map: {sag_id: [list of related sag_ids]}
|
# Build relations map: {sag_id: [list of related sag_ids]}
|
||||||
|
|||||||
@ -2188,6 +2188,9 @@
|
|||||||
<button class="btn btn-sm btn-outline-secondary" onclick="cancelBeskrivelsEdit()">
|
<button class="btn btn-sm btn-outline-secondary" onclick="cancelBeskrivelsEdit()">
|
||||||
<i class="bi bi-x me-1"></i>Annuller
|
<i class="bi bi-x me-1"></i>Annuller
|
||||||
</button>
|
</button>
|
||||||
|
<button id="beskrivelse-rewrite-btn" type="button" class="btn btn-sm btn-outline-primary" onclick="rewriteCaseDescriptionWithApproval()">
|
||||||
|
<i class="bi bi-magic me-1"></i>Renskriv med AI
|
||||||
|
</button>
|
||||||
<button id="beskrivelse-save-btn" class="btn btn-sm btn-primary" onclick="saveBeskrivelsEdit()">
|
<button id="beskrivelse-save-btn" class="btn btn-sm btn-primary" onclick="saveBeskrivelsEdit()">
|
||||||
<i class="bi bi-check2 me-1"></i>Gem
|
<i class="bi bi-check2 me-1"></i>Gem
|
||||||
</button>
|
</button>
|
||||||
@ -4720,7 +4723,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="caseEmailBody" class="form-label form-label-sm mb-1">Besked</label>
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<label for="caseEmailBody" class="form-label form-label-sm mb-0">Besked</label>
|
||||||
|
<button id="caseEmailRewriteBtn" type="button" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-magic me-1"></i>Renskriv med AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<textarea class="form-control form-control-sm" id="caseEmailBody" rows="10" placeholder="Skriv besked..."></textarea>
|
<textarea class="form-control form-control-sm" id="caseEmailBody" rows="10" placeholder="Skriv besked..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -4736,6 +4744,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="rewritePreviewModal" tabindex="-1" aria-labelledby="rewritePreviewModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="rewritePreviewModalLabel"><i class="bi bi-pencil-square me-2"></i>AI forslag til renskrivning</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="rewritePreviewSummary" class="small text-muted mb-3"></div>
|
||||||
|
<div id="rewritePreviewNoChanges" class="alert alert-info d-none mb-3">AI fandt ingen ændringer.</div>
|
||||||
|
<div id="rewritePreviewList" class="d-flex flex-column gap-2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer d-flex justify-content-between">
|
||||||
|
<small id="rewritePreviewSelectionInfo" class="text-muted">Ingen ændringer valgt</small>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" id="rewriteApplySelectedBtn" class="btn btn-outline-primary btn-sm">Godkend valgte</button>
|
||||||
|
<button type="button" id="rewriteApplyAllBtn" class="btn btn-primary btn-sm">Godkend alle</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Solution Tab -->
|
<!-- Solution Tab -->
|
||||||
<div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}" style="display:none;">
|
<div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}" style="display:none;">
|
||||||
<!-- Nextcloud Integration Box -->
|
<!-- Nextcloud Integration Box -->
|
||||||
@ -8583,6 +8615,200 @@
|
|||||||
.replace(/'/g, ''');
|
.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() {
|
function getDefaultCaseRecipient() {
|
||||||
const primaryContact = document.querySelector('.contact-row[data-is-primary="true"][data-email]');
|
const primaryContact = document.querySelector('.contact-row[data-is-primary="true"][data-email]');
|
||||||
if (primaryContact?.dataset?.email) {
|
if (primaryContact?.dataset?.email) {
|
||||||
@ -9289,6 +9515,21 @@
|
|||||||
caseEmailSendBtn.addEventListener('click', sendCaseEmail);
|
caseEmailSendBtn.addEventListener('click', sendCaseEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const caseEmailRewriteBtn = document.getElementById('caseEmailRewriteBtn');
|
||||||
|
if (caseEmailRewriteBtn) {
|
||||||
|
caseEmailRewriteBtn.addEventListener('click', rewriteCaseEmailWithApproval);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewriteApplyAllBtn = document.getElementById('rewriteApplyAllBtn');
|
||||||
|
if (rewriteApplyAllBtn) {
|
||||||
|
rewriteApplyAllBtn.addEventListener('click', () => applyRewriteChanges('all'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewriteApplySelectedBtn = document.getElementById('rewriteApplySelectedBtn');
|
||||||
|
if (rewriteApplySelectedBtn) {
|
||||||
|
rewriteApplySelectedBtn.addEventListener('click', () => applyRewriteChanges('selected'));
|
||||||
|
}
|
||||||
|
|
||||||
const caseEmailComposeModal = document.getElementById('caseEmailComposeModal');
|
const caseEmailComposeModal = document.getElementById('caseEmailComposeModal');
|
||||||
if (caseEmailComposeModal) {
|
if (caseEmailComposeModal) {
|
||||||
caseEmailComposeModal.addEventListener('show.bs.modal', () => {
|
caseEmailComposeModal.addEventListener('show.bs.modal', () => {
|
||||||
@ -10781,6 +11022,86 @@
|
|||||||
const SAG_ID = {{ case.id }};
|
const SAG_ID = {{ case.id }};
|
||||||
let _historyLoaded = false;
|
let _historyLoaded = false;
|
||||||
|
|
||||||
|
window.rewriteCaseDescriptionWithApproval = async function () {
|
||||||
|
const ta = document.getElementById('beskrivelse-textarea');
|
||||||
|
const rewriteBtn = document.getElementById('beskrivelse-rewrite-btn');
|
||||||
|
if (!ta) return;
|
||||||
|
|
||||||
|
const source = (ta.value || '').trim();
|
||||||
|
if (!source) {
|
||||||
|
if (typeof showNotification === 'function') showNotification('Skriv en beskrivelse først', 'warning');
|
||||||
|
else alert('Skriv en beskrivelse først');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalHtml = rewriteBtn?.innerHTML || '';
|
||||||
|
if (rewriteBtn) {
|
||||||
|
rewriteBtn.disabled = true;
|
||||||
|
rewriteBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rewriteEndpoints = ['/api/v1/rewrite-text', '/api/v1/sag/rewrite-text', '/api/v1/emails/rewrite-text'];
|
||||||
|
let payload = null;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (const endpoint of rewriteEndpoints) {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: source, context: 'case' })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
payload = await response.json();
|
||||||
|
lastError = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = `HTTP ${response.status}`;
|
||||||
|
try {
|
||||||
|
const err = await response.json();
|
||||||
|
if (err?.detail) detail = err.detail;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
lastError = new Error(detail);
|
||||||
|
|
||||||
|
// Retry next endpoint for common route mismatch cases.
|
||||||
|
if (![404, 405].includes(response.status)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw lastError || new Error('Kunne ikke hente renskrivningsforslag');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewrittenRaw = String(payload?.rewritten_text || '').trim();
|
||||||
|
const descMatch = rewrittenRaw.match(/(?:^|\n)Beskrivelse:\s*\n([\s\S]*)$/i);
|
||||||
|
const rewritten = descMatch?.[1] ? descMatch[1].trim() : rewrittenRaw;
|
||||||
|
|
||||||
|
openRewriteReviewModal({
|
||||||
|
title: 'Sagsbeskrivelse',
|
||||||
|
originalText: source,
|
||||||
|
rewrittenText: rewritten,
|
||||||
|
applyToTarget: (nextText) => {
|
||||||
|
ta.value = nextText;
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('rewritePreviewModal')).hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (typeof showNotification === 'function') showNotification('Kunne ikke renskrive beskrivelse', 'error');
|
||||||
|
else alert(`Kunne ikke renskrive beskrivelse: ${e.message || 'Ukendt fejl'}`);
|
||||||
|
} finally {
|
||||||
|
if (rewriteBtn) {
|
||||||
|
rewriteBtn.disabled = false;
|
||||||
|
rewriteBtn.innerHTML = originalHtml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.startBeskrivelsEdit = function () {
|
window.startBeskrivelsEdit = function () {
|
||||||
const current = document.getElementById('beskrivelse-text').innerText.trim();
|
const current = document.getElementById('beskrivelse-text').innerText.trim();
|
||||||
document.getElementById('beskrivelse-textarea').value = current;
|
document.getElementById('beskrivelse-textarea').value = current;
|
||||||
|
|||||||
@ -140,6 +140,126 @@ Output: {
|
|||||||
"confidence": 0.95
|
"confidence": 0.95
|
||||||
}""")
|
}""")
|
||||||
|
|
||||||
|
def _get_text_rewrite_prompt(self) -> str:
|
||||||
|
"""Return rewrite prompt with optional DB override from ai_prompts."""
|
||||||
|
default_prompt = """Du er en dansk skriveassistent for IT-support.
|
||||||
|
|
||||||
|
Din opgave er at renskrive en 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:
|
async def extract_from_text(self, text: str) -> Dict:
|
||||||
"""
|
"""
|
||||||
Extract structured invoice data from text using Ollama
|
Extract structured invoice data from text using Ollama
|
||||||
|
|||||||
@ -221,6 +221,29 @@ async def update_setting(key: str, setting: SettingUpdate):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Mission camera settings may not exist on older hubs before migration.
|
||||||
|
if not result and key in {"mission_camera_enabled", "mission_camera_name", "mission_camera_feed_url", "mission_camera_spotlight_seconds", "mission_access_pin"}:
|
||||||
|
defaults = {
|
||||||
|
"mission_camera_enabled": ("mission", "Enable one camera feed in Mission Control", "boolean", True),
|
||||||
|
"mission_camera_name": ("mission", "Display name for Mission Control camera feed", "string", True),
|
||||||
|
"mission_camera_feed_url": ("mission", "Feed URL for Mission Control camera", "string", True),
|
||||||
|
"mission_camera_spotlight_seconds": ("mission", "Camera spotlight duration in seconds for motion events", "integer", True),
|
||||||
|
"mission_access_pin": ("mission", "Access PIN for Mission Control kiosk mode", "string", False),
|
||||||
|
}
|
||||||
|
category, description, value_type, is_public = defaults[key]
|
||||||
|
result = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (key)
|
||||||
|
DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(key, setting.value, category, description, value_type, is_public),
|
||||||
|
)
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Setting not found")
|
raise HTTPException(status_code=404, detail="Setting not found")
|
||||||
|
|
||||||
@ -473,6 +496,42 @@ Output: [Fakturastekst]""",
|
|||||||
"num_predict": 500
|
"num_predict": 500
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"text_rewrite": {
|
||||||
|
"name": "✍️ Omskrivning (Email + Sagsbeskrivelse)",
|
||||||
|
"description": "Renskriver tekst til professionelt dansk for både e-mails og sagsbeskrivelser, uden at ændre fakta.",
|
||||||
|
"model": ollama_service.model,
|
||||||
|
"endpoint": ollama_service.endpoint,
|
||||||
|
"prompt": """Du er en dansk skriveassistent for IT-support.
|
||||||
|
|
||||||
|
Din opgave er at renskrive en 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": {
|
"kb_generation": {
|
||||||
"name": "📚 Vidensbank Generator (Solution to Article)",
|
"name": "📚 Vidensbank Generator (Solution to Article)",
|
||||||
"description": "Omdanner en løst ticket til en generel vejledning til vidensbanken.",
|
"description": "Omdanner en løst ticket til en generel vejledning til vidensbanken.",
|
||||||
@ -594,6 +653,7 @@ def _get_test_input_for_prompt(key: str) -> str:
|
|||||||
"invoice_extraction": "FAKTURA 2026-1001 fra Demo A/S. CVR 12345678. Total 1.250,00 DKK inkl moms.",
|
"invoice_extraction": "FAKTURA 2026-1001 fra Demo A/S. CVR 12345678. Total 1.250,00 DKK inkl moms.",
|
||||||
"ticket_classification": "Emne: Kan ikke logge på VPN. Beskrivelse: Flere brugere er ramt siden i morges.",
|
"ticket_classification": "Emne: Kan ikke logge på VPN. Beskrivelse: Flere brugere er ramt siden i morges.",
|
||||||
"ticket_summary": "Bruger havde netværksfejl. Router genstartet og DNS opdateret. Forbindelse virker nu stabilt.",
|
"ticket_summary": "Bruger havde netværksfejl. Router genstartet og DNS opdateret. Forbindelse virker nu stabilt.",
|
||||||
|
"text_rewrite": "emne: printer virker ikke\nhej\nvi har stadig problem med printeren hos norva24 i hellerup, den stopper hele tiden og brugerne kan ikke printe, kan i kigge på det hurtigst muligt\nvh dennis",
|
||||||
"kb_generation": "Problem: Outlook åbner ikke. Løsning: Reparer Office installation og nulstil profil.",
|
"kb_generation": "Problem: Outlook åbner ikke. Løsning: Reparer Office installation og nulstil profil.",
|
||||||
"troubleshooting_assistant": "Server svarer langsomt efter opdatering. CPU er høj, disk IO er normal.",
|
"troubleshooting_assistant": "Server svarer langsomt efter opdatering. CPU er høj, disk IO er normal.",
|
||||||
"sentiment_analysis": "Jeg er meget frustreret, systemet er nede igen og vi mister kunder!",
|
"sentiment_analysis": "Jeg er meget frustreret, systemet er nede igen og vi mister kunder!",
|
||||||
|
|||||||
@ -113,6 +113,9 @@
|
|||||||
<a class="nav-link" href="#modules" data-tab="modules">
|
<a class="nav-link" href="#modules" data-tab="modules">
|
||||||
<i class="bi bi-box-seam me-2"></i>Moduler
|
<i class="bi bi-box-seam me-2"></i>Moduler
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link" href="#mission" data-tab="mission">
|
||||||
|
<i class="bi bi-broadcast-pin me-2"></i>Mission
|
||||||
|
</a>
|
||||||
<a class="nav-link" href="#system" data-tab="system">
|
<a class="nav-link" href="#system" data-tab="system">
|
||||||
<i class="bi bi-gear me-2"></i>System
|
<i class="bi bi-gear me-2"></i>System
|
||||||
</a>
|
</a>
|
||||||
@ -1099,6 +1102,75 @@ async def scan_document(file_path: str):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mission Settings -->
|
||||||
|
<div class="tab-pane fade" id="mission">
|
||||||
|
<div class="card p-4 mb-4">
|
||||||
|
<h5 class="mb-3 fw-bold">Mission Kamera</h5>
|
||||||
|
<p class="text-muted mb-3">Kamera-konfiguration flyttet hertil, så Mission Control kun viser live drift.</p>
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Aktiver kamera feed</label>
|
||||||
|
<div class="form-check form-switch mt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="missionCameraEnabled">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" for="missionCameraName">Kamera navn</label>
|
||||||
|
<input type="text" class="form-control" id="missionCameraName" placeholder="Mission Kamera">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="missionCameraFeedUrl">Feed URL</label>
|
||||||
|
<input type="text" class="form-control" id="missionCameraFeedUrl" placeholder="rtsp:// eller https://...">
|
||||||
|
<div class="form-text">Understøtter rtsp://, http:// og https://</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" for="missionCameraSpotlightSeconds">Spotlight varighed (sek.)</label>
|
||||||
|
<input type="number" class="form-control" id="missionCameraSpotlightSeconds" min="5" max="120" step="1" value="20">
|
||||||
|
<div class="form-text">Forstørret kameravindue efter motion webhook.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn btn-primary" id="saveMissionCameraSettingsBtn">
|
||||||
|
<i class="bi bi-save me-2"></i>Gem kamera
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="copyMissionWebhookBtn">
|
||||||
|
<i class="bi bi-clipboard me-2"></i>Kopier motion webhook URL
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-warning" type="button" id="diagnoseMissionCameraBtn">
|
||||||
|
<i class="bi bi-activity me-2"></i>Kør diagnose
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div id="missionSettingsFeedback" class="form-text"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12" id="missionCameraDiagnosticsWrap" style="display:none;">
|
||||||
|
<div class="border rounded p-2 bg-light">
|
||||||
|
<strong class="small">Kamera diagnose</strong>
|
||||||
|
<div id="missionCameraDiagnosticsText" class="small text-muted mt-1">Ingen diagnose kørt endnu.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4">
|
||||||
|
<h5 class="mb-3 fw-bold">Mission PIN</h5>
|
||||||
|
<p class="text-muted mb-3">PIN bruges til kiosk-adgang på Mission Control uden almindelig login.</p>
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="missionAccessPinInput">Ny PIN (4-10 cifre)</label>
|
||||||
|
<input type="password" class="form-control" id="missionAccessPinInput" inputmode="numeric" maxlength="10" placeholder="fx 1234">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex gap-2">
|
||||||
|
<button class="btn btn-primary" id="saveMissionPinSettingsBtn">
|
||||||
|
<i class="bi bi-shield-lock me-2"></i>Gem PIN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div id="missionPinSettingsFeedback" class="form-text"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- System Settings -->
|
<!-- System Settings -->
|
||||||
<div class="tab-pane fade" id="system">
|
<div class="tab-pane fade" id="system">
|
||||||
<div class="card p-4 mb-4">
|
<div class="card p-4 mb-4">
|
||||||
@ -1539,6 +1611,214 @@ function renderTelefoniSettings() {
|
|||||||
buildYealinkActionUrls();
|
buildYealinkActionUrls();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setOrAddSettingInCache(key, value) {
|
||||||
|
const existing = allSettings.find(s => s.key === key);
|
||||||
|
if (existing) {
|
||||||
|
existing.value = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allSettings.push({ key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMissionSettingsFeedback(message, type = 'muted') {
|
||||||
|
const feedback = document.getElementById('missionSettingsFeedback');
|
||||||
|
if (!feedback) return;
|
||||||
|
const cls = type === 'error' ? 'text-danger' : type === 'success' ? 'text-success' : 'text-muted';
|
||||||
|
feedback.className = `form-text ${cls}`;
|
||||||
|
feedback.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMissionPinFeedback(message, type = 'muted') {
|
||||||
|
const feedback = document.getElementById('missionPinSettingsFeedback');
|
||||||
|
if (!feedback) return;
|
||||||
|
const cls = type === 'error' ? 'text-danger' : type === 'success' ? 'text-success' : 'text-muted';
|
||||||
|
feedback.className = `form-text ${cls}`;
|
||||||
|
feedback.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMissionSettings() {
|
||||||
|
const enabled = document.getElementById('missionCameraEnabled');
|
||||||
|
const name = document.getElementById('missionCameraName');
|
||||||
|
const feed = document.getElementById('missionCameraFeedUrl');
|
||||||
|
const spotlight = document.getElementById('missionCameraSpotlightSeconds');
|
||||||
|
if (!enabled || !name || !feed || !spotlight) return;
|
||||||
|
|
||||||
|
enabled.checked = getSettingValue('mission_camera_enabled', 'false') === 'true';
|
||||||
|
name.value = getSettingValue('mission_camera_name', 'Mission Kamera');
|
||||||
|
feed.value = getSettingValue('mission_camera_feed_url', '');
|
||||||
|
spotlight.value = String(Math.max(5, Math.min(parseInt(getSettingValue('mission_camera_spotlight_seconds', '20'), 10) || 20, 120)));
|
||||||
|
|
||||||
|
setMissionSettingsFeedback('');
|
||||||
|
setMissionPinFeedback('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMissionCameraSettings() {
|
||||||
|
const enabledEl = document.getElementById('missionCameraEnabled');
|
||||||
|
const nameEl = document.getElementById('missionCameraName');
|
||||||
|
const feedEl = document.getElementById('missionCameraFeedUrl');
|
||||||
|
const spotlightEl = document.getElementById('missionCameraSpotlightSeconds');
|
||||||
|
const btn = document.getElementById('saveMissionCameraSettingsBtn');
|
||||||
|
if (!enabledEl || !nameEl || !feedEl || !spotlightEl || !btn) return;
|
||||||
|
|
||||||
|
const spotlightSeconds = Math.max(5, Math.min(parseInt(spotlightEl.value || '20', 10) || 20, 120));
|
||||||
|
spotlightEl.value = String(spotlightSeconds);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
enabled: !!enabledEl.checked,
|
||||||
|
camera_name: (nameEl.value || '').trim() || 'Mission Kamera',
|
||||||
|
feed_url: (feedEl.value || '').trim(),
|
||||||
|
spotlight_seconds: spotlightSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (payload.feed_url && !/^(rtsp|https?):\/\//i.test(payload.feed_url)) {
|
||||||
|
setMissionSettingsFeedback('Ugyldig feed URL. Brug rtsp/http/https.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
const original = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Gemmer...';
|
||||||
|
setMissionSettingsFeedback('Gemmer kameraindstillinger...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/mission/camera/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && (response.status === 404 || response.status === 405)) {
|
||||||
|
const updates = [
|
||||||
|
{ key: 'mission_camera_enabled', value: payload.enabled ? 'true' : 'false' },
|
||||||
|
{ key: 'mission_camera_name', value: payload.camera_name },
|
||||||
|
{ key: 'mission_camera_feed_url', value: payload.feed_url },
|
||||||
|
{ key: 'mission_camera_spotlight_seconds', value: String(payload.spotlight_seconds) },
|
||||||
|
];
|
||||||
|
for (const setting of updates) {
|
||||||
|
const fallback = await fetch(`/api/v1/settings/${encodeURIComponent(setting.key)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value: setting.value })
|
||||||
|
});
|
||||||
|
if (!fallback.ok) {
|
||||||
|
const detail = await getErrorMessage(fallback, `Kunne ikke gemme ${setting.key}`);
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!response.ok) {
|
||||||
|
const detail = await getErrorMessage(response, 'Kunne ikke gemme Mission kamera');
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrAddSettingInCache('mission_camera_enabled', payload.enabled ? 'true' : 'false');
|
||||||
|
setOrAddSettingInCache('mission_camera_name', payload.camera_name);
|
||||||
|
setOrAddSettingInCache('mission_camera_feed_url', payload.feed_url);
|
||||||
|
setOrAddSettingInCache('mission_camera_spotlight_seconds', String(payload.spotlight_seconds));
|
||||||
|
setMissionSettingsFeedback('Mission kamera gemt.', 'success');
|
||||||
|
showNotification('Mission kamera gemt', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
setMissionSettingsFeedback(error.message || 'Kunne ikke gemme Mission kamera', 'error');
|
||||||
|
showNotification('Kunne ikke gemme Mission kamera', 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyMissionWebhookUrl() {
|
||||||
|
const webhookUrl = `${window.location.origin}/api/v1/mission/webhook/camera/motion?token=DIN_TOKEN_HER`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(webhookUrl);
|
||||||
|
setMissionSettingsFeedback('Webhook URL kopieret til clipboard.', 'success');
|
||||||
|
} catch {
|
||||||
|
setMissionSettingsFeedback(`Webhook URL: ${webhookUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function diagnoseMissionCamera() {
|
||||||
|
const btn = document.getElementById('diagnoseMissionCameraBtn');
|
||||||
|
const wrap = document.getElementById('missionCameraDiagnosticsWrap');
|
||||||
|
const text = document.getElementById('missionCameraDiagnosticsText');
|
||||||
|
if (!btn || !wrap || !text) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
const original = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Tester...';
|
||||||
|
wrap.style.display = '';
|
||||||
|
text.textContent = 'Kører diagnose...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/mission/camera/status');
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = data.detail || `Fejl (${response.status})`;
|
||||||
|
text.textContent = detail;
|
||||||
|
setMissionSettingsFeedback('Diagnose fejlede.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
text.textContent = `Status: ${data.ok ? 'OK' : 'FEJL'} | Enabled: ${data.enabled ? 'ja' : 'nej'} | Type: ${data.feed_scheme || 'ukendt'} | Detail: ${data.detail || '-'}`;
|
||||||
|
setMissionSettingsFeedback(data.ok ? 'Diagnose: stream ser OK ud.' : 'Diagnose: stream har fejl.', data.ok ? 'success' : 'error');
|
||||||
|
} catch (error) {
|
||||||
|
text.textContent = `Netværksfejl: ${error.message || 'Ukendt fejl'}`;
|
||||||
|
setMissionSettingsFeedback('Diagnose fejlede.', 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMissionPinSettings() {
|
||||||
|
const pinInput = document.getElementById('missionAccessPinInput');
|
||||||
|
const btn = document.getElementById('saveMissionPinSettingsBtn');
|
||||||
|
if (!pinInput || !btn) return;
|
||||||
|
|
||||||
|
const pin = (pinInput.value || '').trim();
|
||||||
|
if (!/^\d{4,10}$/.test(pin)) {
|
||||||
|
setMissionPinFeedback('PIN skal være 4-10 cifre.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
const original = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Gemmer...';
|
||||||
|
setMissionPinFeedback('Gemmer PIN...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/mission/access-pin', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pin })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && (response.status === 404 || response.status === 405)) {
|
||||||
|
const fallback = await fetch('/api/v1/settings/mission_access_pin', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value: pin })
|
||||||
|
});
|
||||||
|
if (!fallback.ok) {
|
||||||
|
const detail = await getErrorMessage(fallback, 'Kunne ikke gemme PIN');
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
} else if (!response.ok) {
|
||||||
|
const detail = await getErrorMessage(response, 'Kunne ikke gemme PIN');
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrAddSettingInCache('mission_access_pin', pin);
|
||||||
|
pinInput.value = '';
|
||||||
|
setMissionPinFeedback('Mission PIN gemt.', 'success');
|
||||||
|
showNotification('Mission PIN gemt', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
setMissionPinFeedback(error.message || 'Kunne ikke gemme PIN', 'error');
|
||||||
|
showNotification('Kunne ikke gemme Mission PIN', 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateTelefoniActionPreview() {
|
function updateTelefoniActionPreview() {
|
||||||
const previewEl = document.getElementById('telefoniActionPreview');
|
const previewEl = document.getElementById('telefoniActionPreview');
|
||||||
const template = (document.getElementById('telefoniActionTemplate')?.value || '').trim();
|
const template = (document.getElementById('telefoniActionTemplate')?.value || '').trim();
|
||||||
@ -1705,6 +1985,7 @@ async function loadSettings() {
|
|||||||
allSettings = await response.json();
|
allSettings = await response.json();
|
||||||
displaySettingsByCategory();
|
displaySettingsByCategory();
|
||||||
renderTelefoniSettings();
|
renderTelefoniSettings();
|
||||||
|
renderMissionSettings();
|
||||||
await loadCaseTypesSetting();
|
await loadCaseTypesSetting();
|
||||||
await loadCaseStatusesSetting();
|
await loadCaseStatusesSetting();
|
||||||
await loadTagsManagement();
|
await loadTagsManagement();
|
||||||
@ -3326,6 +3607,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
|||||||
loadTagsManagement();
|
loadTagsManagement();
|
||||||
} else if (tab === 'telefoni') {
|
} else if (tab === 'telefoni') {
|
||||||
renderTelefoniSettings();
|
renderTelefoniSettings();
|
||||||
|
} else if (tab === 'mission') {
|
||||||
|
renderMissionSettings();
|
||||||
} else if (tab === 'ai-prompts') {
|
} else if (tab === 'ai-prompts') {
|
||||||
loadAIPrompts();
|
loadAIPrompts();
|
||||||
} else if (tab === 'modules') {
|
} else if (tab === 'modules') {
|
||||||
@ -4270,6 +4553,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
[yealinkBase, yealinkToken].forEach(el => {
|
[yealinkBase, yealinkToken].forEach(el => {
|
||||||
if (el) el.addEventListener('input', buildYealinkActionUrls);
|
if (el) el.addEventListener('input', buildYealinkActionUrls);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveMissionCameraBtn = document.getElementById('saveMissionCameraSettingsBtn');
|
||||||
|
if (saveMissionCameraBtn) saveMissionCameraBtn.addEventListener('click', saveMissionCameraSettings);
|
||||||
|
const copyMissionWebhookBtn = document.getElementById('copyMissionWebhookBtn');
|
||||||
|
if (copyMissionWebhookBtn) copyMissionWebhookBtn.addEventListener('click', copyMissionWebhookUrl);
|
||||||
|
const diagnoseMissionCameraBtn = document.getElementById('diagnoseMissionCameraBtn');
|
||||||
|
if (diagnoseMissionCameraBtn) diagnoseMissionCameraBtn.addEventListener('click', diagnoseMissionCamera);
|
||||||
|
const saveMissionPinBtn = document.getElementById('saveMissionPinSettingsBtn');
|
||||||
|
if (saveMissionPinBtn) saveMissionPinBtn.addEventListener('click', saveMissionPinSettings);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
37
main.py
37
main.py
@ -39,6 +39,19 @@ def _users_column_exists(column_name: str) -> bool:
|
|||||||
_users_column_cache[column_name] = exists
|
_users_column_cache[column_name] = exists
|
||||||
return exists
|
return exists
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mission_access_pin() -> str:
|
||||||
|
row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("mission_access_pin",))
|
||||||
|
db_pin = str((row or {}).get("value") or "").strip()
|
||||||
|
env_pin = str(getattr(settings, "MISSION_ACCESS_PIN", "") or "").strip()
|
||||||
|
return db_pin or env_pin
|
||||||
|
|
||||||
|
|
||||||
|
def _has_valid_mission_pin_token(request: Request) -> bool:
|
||||||
|
token = request.cookies.get("mission_pin_token")
|
||||||
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
return bool(payload and payload.get("scope") == "mission_pin")
|
||||||
|
|
||||||
def get_version():
|
def get_version():
|
||||||
"""Read version from VERSION file"""
|
"""Read version from VERSION file"""
|
||||||
try:
|
try:
|
||||||
@ -236,16 +249,23 @@ app.add_middleware(
|
|||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def auth_middleware(request: Request, call_next):
|
async def auth_middleware(request: Request, call_next):
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
mission_path = path.startswith("/dashboard/mission-control") or path.startswith("/api/v1/mission/")
|
||||||
|
mission_pin = _get_mission_access_pin() if mission_path else ""
|
||||||
|
|
||||||
public_paths = {
|
public_paths = {
|
||||||
"/health",
|
"/health",
|
||||||
"/login",
|
"/login",
|
||||||
"/api/v1/auth/login"
|
"/api/v1/auth/login",
|
||||||
|
"/mission/pin",
|
||||||
|
"/mission/pin/verify",
|
||||||
|
"/mission/pin/logout",
|
||||||
}
|
}
|
||||||
|
|
||||||
public_prefixes = {
|
public_prefixes = {
|
||||||
"/api/v1/mission/webhook/telefoni/",
|
"/api/v1/mission/webhook/telefoni/",
|
||||||
"/api/v1/mission/webhook/uptime",
|
"/api/v1/mission/webhook/uptime",
|
||||||
|
"/api/v1/mission/webhook/camera/",
|
||||||
|
"/api/v1/mission/webhook/environment/",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Yealink Action URL callbacks (secured inside telefoni module by token/IP)
|
# Yealink Action URL callbacks (secured inside telefoni module by token/IP)
|
||||||
@ -264,12 +284,16 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
path in public_paths
|
path in public_paths
|
||||||
|
or path.startswith("/mission/pin")
|
||||||
or any(path.startswith(prefix) for prefix in public_prefixes)
|
or any(path.startswith(prefix) for prefix in public_prefixes)
|
||||||
or path.startswith("/static")
|
or path.startswith("/static")
|
||||||
or path.startswith("/docs")
|
or path.startswith("/docs")
|
||||||
):
|
):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
if mission_path and _has_valid_mission_pin_token(request):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
token = None
|
token = None
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
if auth_header and auth_header.lower().startswith("bearer "):
|
if auth_header and auth_header.lower().startswith("bearer "):
|
||||||
@ -279,6 +303,17 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
|
|
||||||
payload = AuthService.verify_token(token) if token else None
|
payload = AuthService.verify_token(token) if token else None
|
||||||
if not payload:
|
if not payload:
|
||||||
|
if mission_path:
|
||||||
|
if path.startswith("/api"):
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={
|
||||||
|
"detail": "Mission PIN required" if mission_pin else "Mission PIN not configured"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return RedirectResponse(url="/mission/pin", status_code=302)
|
||||||
|
|
||||||
if path.startswith("/api"):
|
if path.startswith("/api"):
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
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
|
paramiko==3.5.0
|
||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
pdfplumber==0.11.4
|
pdfplumber==0.11.4
|
||||||
|
av==13.1.0
|
||||||
|
Pillow==11.0.0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user