add a monitoring camera setting for cetana

This commit is contained in:
Your Name
2026-10-31 21:08:26 +03:30
parent 33aebe9b14
commit 093e0b9d1a
11 changed files with 732 additions and 9 deletions
+8
View File
@@ -24,6 +24,14 @@ PAYMENT_GATEWAY_MERCHANT_ID=
SMS_PROVIDER=mock
SMS_PROVIDER_API_KEY=
# ---- Live camera monitoring (overrides settings panel defaults) ----
CAMERA_ENABLED=1
CAMERA_DEFAULT_PROTOCOL=hls
CAMERA_STREAM_TTL_SECONDS=3600
CAMERA_PREFER_SECURE=1
CAMERA_MEDIA_GATEWAY_URL=
CAMERA_MEDIA_GATEWAY_PATH_TEMPLATE={camera_id}/index.m3u8
# ---- Celery ----
CELERY_BROKER_URL=redis://localhost:6379/0
+175
View File
@@ -5,7 +5,180 @@ is rendered from this list, so adding a new integration/provider only requires
adding entries here — no frontend or model changes.
Field types: ``string``, ``secret``, ``boolean``, ``choice``, ``number``.
Optional keys:
- ``depends_on``: ``{"other.key": ["value_a", "value_b"]}`` — field is shown
only when every referenced setting currently holds one of the listed values.
- ``options``: list of strings or ``{"value", "label"}`` dicts (for ``choice``).
"""
from apps.live_monitoring.protocols import (
DEFAULT_PROTOCOL,
PROTOCOL_KEYS,
STREAM_PROTOCOLS,
protocol_choice_options,
)
def _camera_settings() -> list:
"""Build Live Monitoring settings from the protocol catalog.
Shared defaults always appear. Protocol-specific fields use ``depends_on``
so the settings panel stays dynamic as standards are added to the catalog.
"""
entries = [
{
"key": "camera.enabled",
"label": "Enable live camera monitoring",
"type": "boolean",
"category": "Live Monitoring",
"default": True,
},
{
"key": "camera.default_protocol",
"label": "Default stream protocol",
"type": "choice",
"category": "Live Monitoring",
"options": protocol_choice_options(),
"default": DEFAULT_PROTOCOL,
},
{
"key": "camera.stream_ttl_seconds",
"label": "Stream access token TTL (seconds)",
"type": "number",
"category": "Live Monitoring",
"default": 3600,
},
{
"key": "camera.prefer_secure",
"label": "Prefer secure schemes (HTTPS / RTSPS / RTMPS)",
"type": "boolean",
"category": "Live Monitoring",
"default": True,
},
{
"key": "camera.media_gateway_url",
"label": "Media gateway base URL",
"type": "string",
"category": "Live Monitoring",
"default": "",
"placeholder": "https://media.example.com",
},
{
"key": "camera.media_gateway_path_template",
"label": "Gateway path template",
"type": "string",
"category": "Live Monitoring",
"default": "{camera_id}/index.m3u8",
"placeholder": "{camera_id}/index.m3u8",
},
{
"key": "camera.default_username",
"label": "Default camera username",
"type": "string",
"category": "Live Monitoring",
"default": "",
},
{
"key": "camera.default_password",
"label": "Default camera password",
"type": "secret",
"category": "Live Monitoring",
"default": "",
},
{
"key": "camera.default_auth_method",
"label": "Default stream auth method",
"type": "choice",
"category": "Live Monitoring",
"options": [
{"value": "none", "label": "None"},
{"value": "basic", "label": "HTTP Basic"},
{"value": "digest", "label": "HTTP Digest"},
{"value": "bearer", "label": "Bearer token"},
{"value": "query_token", "label": "Query token"},
{"value": "ws_username_token", "label": "WS-UsernameToken (ONVIF)"},
{"value": "passphrase", "label": "Passphrase (SRT)"},
],
"default": "none",
},
]
# Protocol-specific defaults — visibility follows camera.default_protocol.
for key in PROTOCOL_KEYS:
meta = STREAM_PROTOCOLS[key]
dep = {"camera.default_protocol": [key]}
entries.append(
{
"key": f"camera.{key}.default_port",
"label": f"{meta['label']} — default port",
"type": "number",
"category": "Live Monitoring",
"default": meta["default_port"],
"depends_on": dep,
}
)
entries.append(
{
"key": f"camera.{key}.default_path",
"label": f"{meta['label']} — default path",
"type": "string",
"category": "Live Monitoring",
"default": meta["default_path"],
"placeholder": meta["default_path"] or "/",
"depends_on": dep,
}
)
if meta.get("transports"):
entries.append(
{
"key": f"camera.{key}.transport",
"label": f"{meta['label']} — transport",
"type": "choice",
"category": "Live Monitoring",
"options": list(meta["transports"]),
"default": meta["transports"][0],
"depends_on": dep,
}
)
# WebRTC needs ICE servers when selected.
entries.append(
{
"key": "camera.webrtc.ice_servers",
"label": "WebRTC ICE servers (JSON)",
"type": "string",
"category": "Live Monitoring",
"default": '[{"urls":"stun:stun.l.google.com:19302"}]',
"placeholder": '[{"urls":"stun:stun.l.google.com:19302"}]',
"depends_on": {"camera.default_protocol": ["webrtc"]},
}
)
# ONVIF profile token when ONVIF is the default.
entries.append(
{
"key": "camera.onvif.profile_token",
"label": "ONVIF media profile token",
"type": "string",
"category": "Live Monitoring",
"default": "",
"placeholder": "Profile_1",
"depends_on": {"camera.default_protocol": ["onvif"]},
}
)
# SRT passphrase (secret) when SRT is selected.
entries.append(
{
"key": "camera.srt.passphrase",
"label": "SRT passphrase",
"type": "secret",
"category": "Live Monitoring",
"default": "",
"depends_on": {"camera.default_protocol": ["srt"]},
}
)
return entries
SETTINGS_SCHEMA = [
# --- Odoo integration ---
{"key": "odoo.enabled", "label": "Enable Odoo integration", "type": "boolean", "category": "Odoo", "default": False},
@@ -25,6 +198,8 @@ SETTINGS_SCHEMA = [
{"key": "reservation.working_hours_end", "label": "Working hours end", "type": "string", "category": "Reservation", "default": "17:00", "placeholder": "HH:MM"},
{"key": "reservation.slot_duration_minutes", "label": "Slot duration (minutes)", "type": "number", "category": "Reservation", "default": 30},
{"key": "reservation.default_capacity", "label": "Default capacity per slot", "type": "number", "category": "Reservation", "default": 2},
# --- Live camera monitoring (protocol catalog driven) ---
*_camera_settings(),
]
SCHEMA_BY_KEY = {entry["key"]: entry for entry in SETTINGS_SCHEMA}
+14
View File
@@ -12,6 +12,8 @@ class SettingsServiceTests(TestCase):
service = SettingsService()
self.assertEqual(service.get("sms.provider"), "mock")
self.assertIs(service.get("odoo.enabled"), False)
self.assertEqual(service.get("camera.default_protocol"), "hls")
self.assertIs(service.get("camera.enabled"), True)
def test_set_and_get(self):
service = SettingsService()
@@ -24,6 +26,18 @@ class SettingsServiceTests(TestCase):
values = service.all_values()
self.assertEqual(values["odoo.api_key"], "••••••")
def test_camera_protocol_settings_are_in_schema(self):
from apps.core.settings_registry import SETTINGS_SCHEMA
keys = {e["key"] for e in SETTINGS_SCHEMA}
self.assertIn("camera.default_protocol", keys)
self.assertIn("camera.rtsp.transport", keys)
self.assertIn("camera.webrtc.ice_servers", keys)
proto = next(e for e in SETTINGS_SCHEMA if e["key"] == "camera.default_protocol")
self.assertTrue(any(isinstance(o, dict) and o.get("value") == "rtsp" for o in proto["options"]))
rtsp_transport = next(e for e in SETTINGS_SCHEMA if e["key"] == "camera.rtsp.transport")
self.assertEqual(rtsp_transport["depends_on"], {"camera.default_protocol": ["rtsp"]})
class JalaliTests(TestCase):
"""The PDF and the customer SMS are the only places dates are rendered for
+38
View File
@@ -0,0 +1,38 @@
from django.contrib import admin
from apps.live_monitoring.models import Camera, LiveStream, WorkflowStepCamera
@admin.register(Camera)
class CameraAdmin(admin.ModelAdmin):
list_display = ("name", "facility", "processing_line", "protocol", "active", "updated_at")
list_filter = ("active", "protocol", "facility")
search_fields = ("name", "facility", "processing_line", "stream_url")
readonly_fields = ("id", "created_at", "updated_at")
fieldsets = (
(None, {"fields": ("name", "facility", "processing_line", "active")}),
(
"Stream",
{
"fields": ("protocol", "stream_url", "auth_config"),
"description": (
"Protocol blank = use Live Monitoring settings default. "
"auth_config stays server-side (username, password, token, host, port, path…)."
),
},
),
("Meta", {"fields": ("id", "created_at", "updated_at")}),
)
@admin.register(LiveStream)
class LiveStreamAdmin(admin.ModelAdmin):
list_display = ("name", "camera", "active", "updated_at")
list_filter = ("active",)
@admin.register(WorkflowStepCamera)
class WorkflowStepCameraAdmin(admin.ModelAdmin):
list_display = ("step", "camera", "id")
list_filter = ("camera",)
search_fields = ("camera__name",)
@@ -0,0 +1,36 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("live_monitoring", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="camera",
name="protocol",
field=models.CharField(
blank=True,
choices=[
("rtsp", "RTSP (Real Time Streaming Protocol)"),
("rtmp", "RTMP (Real-Time Messaging Protocol)"),
("hls", "HLS (HTTP Live Streaming)"),
("dash", "MPEG-DASH"),
("webrtc", "WebRTC"),
("mjpeg", "MJPEG over HTTP"),
("http", "HTTP Progressive (MP4/WebM)"),
("onvif", "ONVIF Media Profile"),
("srt", "SRT (Secure Reliable Transport)"),
],
default="",
help_text="Streaming standard. Leave blank to use the system default protocol.",
max_length=32,
),
),
migrations.AlterField(
model_name="camera",
name="stream_url",
field=models.CharField(max_length=1000),
),
]
+20 -1
View File
@@ -1,20 +1,39 @@
from django.db import models
from apps.core.models import TimeStampedModel, UUIDModel
from apps.live_monitoring.protocols import DEFAULT_PROTOCOL, PROTOCOL_CHOICES
class Camera(UUIDModel, TimeStampedModel):
name = models.CharField(max_length=200)
facility = models.CharField(max_length=200, blank=True)
processing_line = models.CharField(max_length=200, blank=True)
stream_url = models.URLField()
# Blank = inherit camera.default_protocol from Live Monitoring settings.
protocol = models.CharField(
max_length=32,
choices=PROTOCOL_CHOICES,
blank=True,
default="",
help_text="Streaming standard. Leave blank to use the system default protocol.",
)
# CharField (not URLField) so RTSP/RTMP/SRT URLs are accepted.
stream_url = models.CharField(max_length=1000)
# Never expose credentials to the frontend; keep auth config server-side.
# Expected keys: username, password, token, auth_method, host, port, path, transport…
auth_config = models.JSONField(default=dict, blank=True)
active = models.BooleanField(default=True)
def __str__(self) -> str:
return self.name
def resolved_protocol(self) -> str:
if self.protocol:
return self.protocol
from apps.core.settings_service import SettingsService
value = SettingsService().get("camera.default_protocol", DEFAULT_PROTOCOL)
return value if value in dict(PROTOCOL_CHOICES) else DEFAULT_PROTOCOL
class LiveStream(UUIDModel, TimeStampedModel):
camera = models.ForeignKey(Camera, on_delete=models.CASCADE, related_name="streams")
+165
View File
@@ -0,0 +1,165 @@
"""Standard IP-camera / live-stream protocol catalog.
This is the single source of truth for which streaming standards Cetana
supports. Settings schema entries and Camera.protocol choices are derived
from this registry, so adding a new standard only requires one entry here.
"""
from __future__ import annotations
from typing import Any
# Each protocol describes how streams are addressed and whether a browser can
# play them natively (without a media gateway / WebRTC stack).
STREAM_PROTOCOLS: dict[str, dict[str, Any]] = {
"rtsp": {
"label": "RTSP (Real Time Streaming Protocol)",
"schemes": ("rtsp", "rtsps"),
"default_port": 554,
"default_path": "stream1",
"browser_native": False,
"mime_type": "",
"transports": ("tcp", "udp"),
"auth_methods": ("none", "basic", "digest"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Most IP cameras. Needs a media gateway (e.g. MediaMTX) for browser playback.",
},
"rtmp": {
"label": "RTMP (Real-Time Messaging Protocol)",
"schemes": ("rtmp", "rtmps"),
"default_port": 1935,
"default_path": "live/stream",
"browser_native": False,
"mime_type": "",
"transports": (),
"auth_methods": ("none", "basic"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Common for encoders/NVR publish. Needs HLS/WebRTC remux for browsers.",
},
"hls": {
"label": "HLS (HTTP Live Streaming)",
"schemes": ("https", "http"),
"default_port": 443,
"default_path": "live/index.m3u8",
"browser_native": True,
"mime_type": "application/vnd.apple.mpegurl",
"transports": (),
"auth_methods": ("none", "basic", "bearer", "query_token"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Browser-friendly (.m3u8). Preferred default for web monitoring.",
},
"dash": {
"label": "MPEG-DASH",
"schemes": ("https", "http"),
"default_port": 443,
"default_path": "live/manifest.mpd",
"browser_native": True,
"mime_type": "application/dash+xml",
"transports": (),
"auth_methods": ("none", "basic", "bearer", "query_token"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Adaptive HTTP streaming via MPD manifest.",
},
"webrtc": {
"label": "WebRTC",
"schemes": ("https", "http", "wss", "ws"),
"default_port": 443,
"default_path": "webrtc/offer",
"browser_native": True,
"mime_type": "",
"transports": ("udp", "tcp"),
"auth_methods": ("none", "bearer", "query_token"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Low-latency browser playback via a signaling endpoint.",
},
"mjpeg": {
"label": "MJPEG over HTTP",
"schemes": ("https", "http"),
"default_port": 80,
"default_path": "video.mjpg",
"browser_native": True,
"mime_type": "multipart/x-mixed-replace",
"transports": (),
"auth_methods": ("none", "basic", "digest"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Simple motion-JPEG snapshot stream; plays in <img> or <video> depending on camera.",
},
"http": {
"label": "HTTP Progressive (MP4/WebM)",
"schemes": ("https", "http"),
"default_port": 443,
"default_path": "live.mp4",
"browser_native": True,
"mime_type": "video/mp4",
"transports": (),
"auth_methods": ("none", "basic", "bearer", "query_token"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Progressive download / pseudo-live file URL.",
},
"onvif": {
"label": "ONVIF Media Profile",
"schemes": ("http", "https"),
"default_port": 80,
"default_path": "onvif/device_service",
"browser_native": False,
"mime_type": "",
"transports": (),
"auth_methods": ("none", "digest", "ws_username_token"),
"url_template": "{scheme}://{host}:{port}/{path}",
"notes": "Device discovery/control. Stream URI is usually resolved to RTSP by the NVR.",
},
"srt": {
"label": "SRT (Secure Reliable Transport)",
"schemes": ("srt",),
"default_port": 9000,
"default_path": "",
"browser_native": False,
"mime_type": "",
"transports": ("udp",),
"auth_methods": ("none", "passphrase"),
"url_template": "{scheme}://{host}:{port}",
"notes": "Low-latency contribution protocol. Needs a gateway for browsers.",
},
}
PROTOCOL_CHOICES = [(key, meta["label"]) for key, meta in STREAM_PROTOCOLS.items()]
PROTOCOL_KEYS = tuple(STREAM_PROTOCOLS.keys())
DEFAULT_PROTOCOL = "hls"
def get_protocol(key: str | None) -> dict[str, Any]:
if key and key in STREAM_PROTOCOLS:
return STREAM_PROTOCOLS[key]
return STREAM_PROTOCOLS[DEFAULT_PROTOCOL]
def protocol_choice_options() -> list[dict[str, str]]:
"""Settings-panel friendly {value, label} options."""
return [{"value": key, "label": meta["label"]} for key, meta in STREAM_PROTOCOLS.items()]
def detect_protocol_from_url(url: str) -> str | None:
"""Best-effort guess from URL scheme / path."""
lower = (url or "").strip().lower()
if not lower:
return None
if lower.startswith("rtsps://") or lower.startswith("rtsp://"):
return "rtsp"
if lower.startswith("rtmps://") or lower.startswith("rtmp://"):
return "rtmp"
if lower.startswith("srt://"):
return "srt"
if ".m3u8" in lower:
return "hls"
if ".mpd" in lower:
return "dash"
if any(token in lower for token in (".mjpg", ".mjpeg", "multipart")):
return "mjpeg"
if "webrtc" in lower or lower.startswith("wss://") or lower.startswith("ws://"):
return "webrtc"
if "onvif" in lower:
return "onvif"
if lower.startswith("http://") or lower.startswith("https://"):
return "http"
return None
+10 -2
View File
@@ -4,7 +4,15 @@ from apps.live_monitoring.models import Camera
class CameraSerializer(serializers.ModelSerializer):
# auth_config is never exposed to the frontend.
"""Public camera card. Stream URLs and credentials are never listed here —
clients must go through the tokenized ``access`` / ``verify`` flow.
"""
protocol = serializers.SerializerMethodField()
class Meta:
model = Camera
fields = ["id", "name", "facility", "processing_line", "stream_url", "active"]
fields = ["id", "name", "facility", "processing_line", "protocol", "active"]
def get_protocol(self, obj: Camera) -> str:
return obj.resolved_protocol()
+167 -3
View File
@@ -2,17 +2,29 @@
Camera credentials are never exposed. A customer can request short-lived,
HMAC-signed access tokens for streams authorized for their own orders; the
`verify` endpoint validates the token and returns the stream URL.
`verify` endpoint validates the token and returns a protocol-aware playable
stream descriptor (URL rewritten through a media gateway when needed).
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import Any
from urllib.parse import urlencode, urlsplit, urlunsplit
from django.conf import settings
from apps.core.settings_service import SettingsService
from apps.live_monitoring.models import Camera, WorkflowStepCamera
from apps.live_monitoring.protocols import (
DEFAULT_PROTOCOL,
STREAM_PROTOCOLS,
detect_protocol_from_url,
get_protocol,
)
from apps.orders.models import ProcessingOrder
from apps.workflows.models import WorkflowStep
@@ -31,8 +43,17 @@ class StreamAccessService:
).values_list("id", flat=True)
return WorkflowStepCamera.objects.filter(camera=camera, step_id__in=step_ids).exists()
def issue_token(self, camera: Camera, ttl: int = 3600) -> tuple:
payload = {"camera": str(camera.id), "exp": int(time.time()) + ttl}
def _ttl(self, ttl: int | None) -> int:
if ttl is not None:
return int(ttl)
try:
return int(SettingsService().get("camera.stream_ttl_seconds", 3600))
except (TypeError, ValueError):
return 3600
def issue_token(self, camera: Camera, ttl: int | None = None) -> tuple:
resolved_ttl = self._ttl(ttl)
payload = {"camera": str(camera.id), "exp": int(time.time()) + resolved_ttl}
raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
sig = hmac.new(settings.SECRET_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()
return f"{raw}.{sig}", payload["exp"]
@@ -53,3 +74,146 @@ class StreamAccessService:
if payload.get("exp", 0) < int(time.time()):
return None
return payload
def resolve_stream(self, camera: Camera) -> dict[str, Any]:
"""Build a client-safe stream descriptor for an authorized camera.
Credentials from ``auth_config`` / global settings are applied
server-side only. When the protocol is not browser-native and a media
gateway is configured, the URL is rewritten to the gateway playlist.
"""
service = SettingsService()
if not service.get_bool("camera.enabled", True):
return {
"protocol": camera.resolved_protocol(),
"stream_url": None,
"browser_native": False,
"mime_type": "",
"player": "none",
"error": "Live monitoring is disabled in settings.",
}
protocol_key = camera.protocol or detect_protocol_from_url(camera.stream_url) or camera.resolved_protocol()
if protocol_key not in STREAM_PROTOCOLS:
protocol_key = DEFAULT_PROTOCOL
meta = get_protocol(protocol_key)
playable_url = self._build_playable_url(camera, protocol_key, service)
browser_native = bool(meta.get("browser_native"))
# Non-browser protocols need a gateway remux (typically to HLS).
if not browser_native:
gateway = (service.get("camera.media_gateway_url", "") or "").rstrip("/")
if gateway:
template = service.get("camera.media_gateway_path_template", "{camera_id}/index.m3u8") or "{camera_id}/index.m3u8"
path = template.format(camera_id=str(camera.id), protocol=protocol_key)
playable_url = f"{gateway}/{path.lstrip('/')}"
protocol_key = "hls"
meta = get_protocol("hls")
browser_native = True
else:
return {
"protocol": protocol_key,
"stream_url": None,
"browser_native": False,
"mime_type": meta.get("mime_type", ""),
"player": "none",
"transport": self._transport(protocol_key, camera, service),
"error": (
f"{meta['label']} is not playable in the browser. "
"Configure a media gateway URL in Live Monitoring settings."
),
}
player = self._player_hint(protocol_key)
result: dict[str, Any] = {
"protocol": protocol_key,
"stream_url": playable_url,
"browser_native": browser_native,
"mime_type": meta.get("mime_type", ""),
"player": player,
"transport": self._transport(protocol_key, camera, service),
}
if protocol_key == "webrtc":
ice = service.get("camera.webrtc.ice_servers", "")
if ice:
result["ice_servers"] = ice
return result
def _transport(self, protocol_key: str, camera: Camera, service: SettingsService) -> str | None:
auth = camera.auth_config or {}
if auth.get("transport"):
return auth["transport"]
value = service.get(f"camera.{protocol_key}.transport")
return value or None
def _player_hint(self, protocol_key: str) -> str:
return {
"hls": "hls",
"dash": "dash",
"webrtc": "webrtc",
"mjpeg": "mjpeg",
"http": "video",
}.get(protocol_key, "none")
def _build_playable_url(self, camera: Camera, protocol_key: str, service: SettingsService) -> str:
"""Return a URL safe to hand to the client (no embedded passwords)."""
raw = (camera.stream_url or "").strip()
auth = dict(camera.auth_config or {})
# Absolute URL already provided — strip userinfo credentials.
if "://" in raw:
return self._strip_userinfo(raw)
# Relative / host-based config: assemble from protocol defaults + auth_config.
meta = get_protocol(protocol_key)
prefer_secure = service.get_bool("camera.prefer_secure", True)
schemes = meta["schemes"]
scheme = schemes[0]
if prefer_secure:
for candidate in schemes:
if candidate.endswith("s") or candidate in {"https", "wss", "rtsps", "rtmps"}:
scheme = candidate
break
host = auth.get("host") or raw
if not host:
return raw
port = auth.get("port")
if port in (None, ""):
port = service.get(f"camera.{protocol_key}.default_port", meta["default_port"])
path = (auth.get("path") or service.get(f"camera.{protocol_key}.default_path", meta["default_path"]) or "").lstrip("/")
netloc = f"{host}:{port}" if port not in (None, "") else host
url = urlunsplit((scheme, netloc, path, "", ""))
# Query-token auth can be appended without embedding secrets in userinfo.
auth_method = (
auth.get("auth_method")
or service.get("camera.default_auth_method", "none")
or "none"
)
token = auth.get("token") or auth.get("password")
if auth_method == "query_token" and token:
sep = "&" if "?" in url else "?"
url = f"{url}{sep}{urlencode({'token': token})}"
return url
@staticmethod
def _strip_userinfo(url: str) -> str:
try:
parts = urlsplit(url)
except ValueError:
return url
if not parts.username and not parts.password:
return url
host = parts.hostname or ""
if parts.port:
host = f"{host}:{parts.port}"
# Bracket IPv6
if parts.hostname and ":" in parts.hostname and not host.startswith("["):
host = f"[{parts.hostname}]"
if parts.port:
host = f"{host}:{parts.port}"
return urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
+78 -1
View File
@@ -1,12 +1,18 @@
from django.test import TestCase
from apps.core.settings_service import SettingsService
from apps.live_monitoring.models import Camera
from apps.live_monitoring.protocols import PROTOCOL_KEYS, detect_protocol_from_url
from apps.live_monitoring.services import StreamAccessService
class StreamAccessTests(TestCase):
def setUp(self):
self.camera = Camera.objects.create(name="Cam1", stream_url="https://example.com/live.m3u8")
self.camera = Camera.objects.create(
name="Cam1",
protocol="hls",
stream_url="https://example.com/live.m3u8",
)
self.service = StreamAccessService()
def test_issue_and_verify_token(self):
@@ -24,3 +30,74 @@ class StreamAccessTests(TestCase):
def test_expired_token_rejected(self):
token, _ = self.service.issue_token(self.camera, ttl=-1)
self.assertIsNone(self.service.verify_token(token))
def test_ttl_comes_from_settings(self):
SettingsService().set("camera.stream_ttl_seconds", 120)
token, exp = self.service.issue_token(self.camera)
payload = self.service.verify_token(token)
self.assertAlmostEqual(payload["exp"], exp, delta=1)
# Token should expire roughly 120s from now (not the old 3600 default).
import time
self.assertLess(payload["exp"] - int(time.time()), 130)
def test_resolve_hls_stream(self):
result = self.service.resolve_stream(self.camera)
self.assertEqual(result["protocol"], "hls")
self.assertEqual(result["stream_url"], "https://example.com/live.m3u8")
self.assertTrue(result["browser_native"])
self.assertEqual(result["player"], "hls")
def test_rtsp_requires_gateway_or_errors(self):
cam = Camera.objects.create(
name="RTSP Cam",
protocol="rtsp",
stream_url="rtsp://user:secret@192.168.1.10:554/stream1",
)
result = self.service.resolve_stream(cam)
self.assertIsNone(result["stream_url"])
self.assertIn("media gateway", result["error"].lower())
def test_rtsp_rewrites_through_gateway(self):
SettingsService().set("camera.media_gateway_url", "https://media.example.com")
cam = Camera.objects.create(
name="RTSP Cam",
protocol="rtsp",
stream_url="rtsp://user:secret@192.168.1.10:554/stream1",
)
result = self.service.resolve_stream(cam)
self.assertEqual(result["protocol"], "hls")
self.assertEqual(result["stream_url"], f"https://media.example.com/{cam.id}/index.m3u8")
self.assertNotIn("secret", result["stream_url"])
def test_strips_embedded_credentials(self):
cam = Camera.objects.create(
name="Cred Cam",
protocol="http",
stream_url="https://user:pass@cdn.example.com/live.mp4",
)
result = self.service.resolve_stream(cam)
self.assertEqual(result["stream_url"], "https://cdn.example.com/live.mp4")
def test_inherits_default_protocol_from_settings(self):
SettingsService().set("camera.default_protocol", "mjpeg")
cam = Camera.objects.create(name="Inherit", stream_url="https://cam/video.mjpg")
self.assertEqual(cam.resolved_protocol(), "mjpeg")
def test_monitoring_disabled(self):
SettingsService().set("camera.enabled", False)
result = self.service.resolve_stream(self.camera)
self.assertIsNone(result["stream_url"])
self.assertIn("disabled", result["error"].lower())
class ProtocolCatalogTests(TestCase):
def test_all_standard_protocols_registered(self):
for key in ("rtsp", "rtmp", "hls", "dash", "webrtc", "mjpeg", "http", "onvif", "srt"):
self.assertIn(key, PROTOCOL_KEYS)
def test_detect_from_url(self):
self.assertEqual(detect_protocol_from_url("rtsp://x/y"), "rtsp")
self.assertEqual(detect_protocol_from_url("https://x/a.m3u8"), "hls")
self.assertEqual(detect_protocol_from_url("https://x/a.mpd"), "dash")
self.assertEqual(detect_protocol_from_url("srt://1.2.3.4:9000"), "srt")
+21 -2
View File
@@ -1,6 +1,7 @@
from rest_framework import decorators, mixins, permissions, response, status, views, viewsets
from apps.core.errors import error_response
from apps.core.settings_service import SettingsService
from apps.live_monitoring.models import Camera
from apps.live_monitoring.serializers import CameraSerializer
from apps.live_monitoring.services import StreamAccessService
@@ -13,8 +14,18 @@ class CameraViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.G
permission_classes = [ActionPermission]
code_map = {"list": "processing.view", "retrieve": "processing.view", "access": "processing.view"}
def list(self, request, *args, **kwargs):
if not SettingsService().get_bool("camera.enabled", True):
return response.Response({"count": 0, "next": None, "previous": None, "results": []})
return super().list(request, *args, **kwargs)
@decorators.action(detail=True, methods=["post"])
def access(self, request, pk=None):
if not SettingsService().get_bool("camera.enabled", True):
return response.Response(
error_response("MONITORING_DISABLED", "Live monitoring is disabled."),
status=status.HTTP_403_FORBIDDEN,
)
camera = self.get_object()
service = StreamAccessService()
if not service.can_access(request.user, camera):
@@ -31,8 +42,14 @@ class VerifyStreamView(views.APIView):
permission_classes = [permissions.AllowAny]
def get(self, request):
if not SettingsService().get_bool("camera.enabled", True):
return response.Response(
error_response("MONITORING_DISABLED", "Live monitoring is disabled."),
status=status.HTTP_403_FORBIDDEN,
)
token = request.query_params.get("token")
payload = StreamAccessService().verify_token(token)
service = StreamAccessService()
payload = service.verify_token(token)
if not payload:
return response.Response(
error_response("INVALID_TOKEN", "Invalid or expired stream token."),
@@ -45,4 +62,6 @@ class VerifyStreamView(views.APIView):
error_response("CAMERA_NOT_FOUND", "Camera not found."),
status=status.HTTP_404_NOT_FOUND,
)
return response.Response({"stream_url": camera.stream_url})
stream = service.resolve_stream(camera)
# Keep ``url`` alias for older clients; ``stream_url`` is canonical.
return response.Response({**stream, "url": stream.get("stream_url")})