move_detect/edge/events.py
유창욱 930490d1b4 feat(cloud,edge): event dedup (event_uuid) + site-scoped access control (P1-9, P3-2b)
Two approved data-model changes.

P1-9 — ingest idempotency. A POST can succeed server-side but time out
client-side; the edge then re-queues and re-sends the same event, so the
cloud stored duplicate alerts. The edge Event now carries a stable
event_uuid (uuid4 hex) generated once at creation and preserved through
the offline queue and every retry. Cloud: events.event_uuid CHAR(32) NULL
UNIQUE; EventValidator accepts/validates it; IngestController returns the
already-stored event (200, dedup:true) on a repeat uuid, race-safe against
the UNIQUE key (catches the insert-race PDOException and returns the
winner).

P3-2b — site-scoped access control. site_scope was stored in the session
but never enforced, so any authenticated user could read every site's
worker snapshots (PIPA). Added devices.site; ingest denormalizes it onto
events.site (server-derived from the authenticated device_id, never
client-supplied). App\Security\SiteScope centralizes the rule (NULL scope
= global operator). The event list filters in SQL (page(...,$site)); event
detail + acknowledge and the snapshot proxy guard per-row and return 404
(not 403) cross-site so existence never leaks.

Schema migration ALTERs and per-device/user provisioning UPDATEs are
documented at the bottom of schema.sql.

Tests: edge queue round-trip keeps the uuid; repeat-uuid dedups to one
row; list scoped to caller site; detail cross-site 404; snapshot
cross-site 404 / same-site 200; event_uuid validation. phpunit 97 passing
(4 skipped: PDO), pytest 158 passing (2 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
2026-07-17 12:58:27 +09:00

70 lines
2.1 KiB
Python

import uuid as _uuid
from dataclasses import dataclass, field
from enum import Enum
class EventType(str, Enum):
FALL = "fall"
NO_MOTION = "no_motion"
IMMOBILITY = "immobility"
GHOST = "ghost"
ZONE_WARNING = "zone_warning"
ZONE_RESTRICTED = "zone_restricted"
PINCH = "pinch"
MONITORING_DEGRADED = "monitoring_degraded"
DEVICE_FAULT = "device_fault"
class Severity(str, Enum):
CRITICAL = "critical"
HIGH = "high"
WARNING = "warning"
INFO = "info"
_SEVERITY = {
EventType.NO_MOTION: Severity.CRITICAL,
EventType.GHOST: Severity.CRITICAL,
EventType.FALL: Severity.HIGH,
EventType.ZONE_RESTRICTED: Severity.HIGH,
EventType.PINCH: Severity.HIGH,
EventType.IMMOBILITY: Severity.WARNING,
EventType.ZONE_WARNING: Severity.WARNING,
EventType.MONITORING_DEGRADED: Severity.WARNING,
EventType.DEVICE_FAULT: Severity.WARNING,
}
def severity_for(event_type: EventType) -> Severity:
return _SEVERITY.get(event_type, Severity.INFO)
@dataclass(frozen=True)
class Event:
event_type: EventType
severity: Severity
camera_id: str
track_id: int | None
zone_id: str | None
occurred_at: float
duration_s: float
metrics: dict
spatial: tuple | None # (x, y) 유령사고 등 공간 기준
# Stable idempotency key: generated ONCE at event creation and carried unchanged through the
# offline queue and every retry, so a POST that succeeded server-side but timed out
# client-side is deduplicated by the cloud (see cloud IngestController) instead of stored twice.
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
def to_payload(self, device_id: str) -> dict:
return {
"device_id": device_id,
"event_uuid": self.event_uuid,
"camera_id": self.camera_id,
"event_type": self.event_type.value,
"severity": self.severity.value,
"track_id": self.track_id,
"zone_id": self.zone_id,
"occurred_at": self.occurred_at,
"duration_s": round(self.duration_s, 2),
"metrics": self.metrics,
}