feat: add REST API endpoints for events, known objects, and settings
Routes package with CRUD endpoints: event listing/filtering/tagging/dismissal, known object management with thumbnails, settings CRUD, and stats. 64 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,9 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from config import config
|
from config import config
|
||||||
from database import close_connection, get_connection, init_db, set_db_path
|
from database import close_connection, get_connection, init_db, set_db_path
|
||||||
from matrix_client import MatrixClient
|
from matrix_client import MatrixClient
|
||||||
|
from routes.events import router as events_router
|
||||||
|
from routes.known_objects import router as known_objects_router
|
||||||
|
from routes.settings import router as settings_router
|
||||||
from vision import analyze_snapshot
|
from vision import analyze_snapshot
|
||||||
|
|
||||||
# --- Logging ---
|
# --- Logging ---
|
||||||
@@ -253,6 +256,11 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Include API routers
|
||||||
|
app.include_router(events_router)
|
||||||
|
app.include_router(known_objects_router)
|
||||||
|
app.include_router(settings_router)
|
||||||
|
|
||||||
# Mount static files directory
|
# Mount static files directory
|
||||||
static_dir = Path(__file__).parent / "static"
|
static_dir = Path(__file__).parent / "static"
|
||||||
static_dir.mkdir(exist_ok=True)
|
static_dir.mkdir(exist_ok=True)
|
||||||
|
|||||||
+16
-7
@@ -40,10 +40,15 @@ class KnownObjectResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TagEventRequest(BaseModel):
|
class TagEventRequest(BaseModel):
|
||||||
"""Request model for tagging an event with a known object."""
|
"""Request model for tagging an event with a known object.
|
||||||
|
|
||||||
known_object_id: int
|
Either provide known_object_id to link to an existing object,
|
||||||
match_status: str = Field(default="known", pattern="^(known|unknown|ambiguous)$")
|
or provide name + type to create a new known object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
known_object_id: Optional[int] = None
|
||||||
|
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||||
|
type: Optional[str] = Field(default=None, pattern="^(person|vehicle|pet)$")
|
||||||
|
|
||||||
|
|
||||||
class EventResponse(BaseModel):
|
class EventResponse(BaseModel):
|
||||||
@@ -54,8 +59,11 @@ class EventResponse(BaseModel):
|
|||||||
camera: str
|
camera: str
|
||||||
label: str
|
label: str
|
||||||
score: float
|
score: float
|
||||||
|
has_snapshot: bool = False
|
||||||
|
snapshot_base64: Optional[str] = None
|
||||||
match_status: str
|
match_status: str
|
||||||
matched_object_id: Optional[int] = None
|
matched_object_id: Optional[int] = None
|
||||||
|
matched_object_name: Optional[str] = None
|
||||||
match_confidence: Optional[float] = None
|
match_confidence: Optional[float] = None
|
||||||
ai_description: Optional[str] = None
|
ai_description: Optional[str] = None
|
||||||
notified: bool
|
notified: bool
|
||||||
@@ -79,8 +87,9 @@ class SettingsResponse(BaseModel):
|
|||||||
class StatsResponse(BaseModel):
|
class StatsResponse(BaseModel):
|
||||||
"""Response model for application statistics."""
|
"""Response model for application statistics."""
|
||||||
|
|
||||||
events_processed: int
|
total_events: int = 0
|
||||||
mqtt_connected: bool
|
known_matches: int = 0
|
||||||
last_event: Optional[str] = None
|
unknown_events: int = 0
|
||||||
total_events_stored: int = 0
|
unreviewed: int = 0
|
||||||
known_objects_count: int = 0
|
known_objects_count: int = 0
|
||||||
|
events_today: int = 0
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""Event API endpoints for frigate-notify v2."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from database import get_connection
|
||||||
|
from models import EventResponse, TagEventRequest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/events", tags=["events"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[EventResponse])
|
||||||
|
async def list_events(
|
||||||
|
camera: Optional[str] = Query(default=None),
|
||||||
|
label: Optional[str] = Query(default=None),
|
||||||
|
status: Optional[str] = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
):
|
||||||
|
"""List events with optional filters and pagination.
|
||||||
|
|
||||||
|
Returns events without snapshot/embedding BLOBs but with has_snapshot bool.
|
||||||
|
Joins to known_objects for matched_object_name.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
e.id, e.frigate_event_id, e.camera, e.label, e.score,
|
||||||
|
CASE WHEN e.snapshot IS NOT NULL THEN 1 ELSE 0 END AS has_snapshot,
|
||||||
|
e.match_status, e.matched_object_id,
|
||||||
|
ko.name AS matched_object_name,
|
||||||
|
e.match_confidence, e.ai_description, e.notified, e.created_at
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN known_objects ko ON e.matched_object_id = ko.id
|
||||||
|
"""
|
||||||
|
conditions = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if camera:
|
||||||
|
conditions.append("e.camera = ?")
|
||||||
|
params.append(camera)
|
||||||
|
if label:
|
||||||
|
conditions.append("e.label = ?")
|
||||||
|
params.append(label)
|
||||||
|
if status:
|
||||||
|
conditions.append("e.match_status = ?")
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
query += " WHERE " + " AND ".join(conditions)
|
||||||
|
|
||||||
|
query += " ORDER BY e.created_at DESC LIMIT ? OFFSET ?"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
cursor = conn.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
EventResponse(
|
||||||
|
id=row["id"],
|
||||||
|
frigate_event_id=row["frigate_event_id"],
|
||||||
|
camera=row["camera"],
|
||||||
|
label=row["label"],
|
||||||
|
score=row["score"],
|
||||||
|
has_snapshot=bool(row["has_snapshot"]),
|
||||||
|
match_status=row["match_status"],
|
||||||
|
matched_object_id=row["matched_object_id"],
|
||||||
|
matched_object_name=row["matched_object_name"],
|
||||||
|
match_confidence=row["match_confidence"],
|
||||||
|
ai_description=row["ai_description"],
|
||||||
|
notified=bool(row["notified"]),
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{event_id}", response_model=EventResponse)
|
||||||
|
async def get_event(event_id: int):
|
||||||
|
"""Get a single event detail. Includes base64-encoded snapshot if exists."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
e.id, e.frigate_event_id, e.camera, e.label, e.score,
|
||||||
|
e.snapshot,
|
||||||
|
CASE WHEN e.snapshot IS NOT NULL THEN 1 ELSE 0 END AS has_snapshot,
|
||||||
|
e.match_status, e.matched_object_id,
|
||||||
|
ko.name AS matched_object_name,
|
||||||
|
e.match_confidence, e.ai_description, e.notified, e.created_at
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN known_objects ko ON e.matched_object_id = ko.id
|
||||||
|
WHERE e.id = ?
|
||||||
|
""",
|
||||||
|
(event_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
snapshot_b64 = None
|
||||||
|
if row["snapshot"] is not None:
|
||||||
|
snapshot_b64 = base64.b64encode(row["snapshot"]).decode("ascii")
|
||||||
|
|
||||||
|
return EventResponse(
|
||||||
|
id=row["id"],
|
||||||
|
frigate_event_id=row["frigate_event_id"],
|
||||||
|
camera=row["camera"],
|
||||||
|
label=row["label"],
|
||||||
|
score=row["score"],
|
||||||
|
has_snapshot=bool(row["has_snapshot"]),
|
||||||
|
snapshot_base64=snapshot_b64,
|
||||||
|
match_status=row["match_status"],
|
||||||
|
matched_object_id=row["matched_object_id"],
|
||||||
|
matched_object_name=row["matched_object_name"],
|
||||||
|
match_confidence=row["match_confidence"],
|
||||||
|
ai_description=row["ai_description"],
|
||||||
|
notified=bool(row["notified"]),
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{event_id}/snapshot")
|
||||||
|
async def get_event_snapshot(event_id: int):
|
||||||
|
"""Return raw JPEG snapshot for an event. 404 if no snapshot."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT snapshot FROM events WHERE id = ?",
|
||||||
|
(event_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
if row["snapshot"] is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No snapshot available")
|
||||||
|
|
||||||
|
return Response(content=row["snapshot"], media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_id}/tag")
|
||||||
|
async def tag_event(event_id: int, body: TagEventRequest):
|
||||||
|
"""Tag an event as a known object.
|
||||||
|
|
||||||
|
Either provide known_object_id to link to an existing object,
|
||||||
|
or provide name + type to create a new known object.
|
||||||
|
If the event has an embedding, store it in the embeddings table.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
# Verify event exists
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id, embedding FROM events WHERE id = ?",
|
||||||
|
(event_id,),
|
||||||
|
)
|
||||||
|
event_row = cursor.fetchone()
|
||||||
|
if not event_row:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
known_object_id = body.known_object_id
|
||||||
|
|
||||||
|
if known_object_id is not None:
|
||||||
|
# Verify the known object exists
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id FROM known_objects WHERE id = ?",
|
||||||
|
(known_object_id,),
|
||||||
|
)
|
||||||
|
if not cursor.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Known object not found")
|
||||||
|
elif body.name and body.type:
|
||||||
|
# Create a new known object
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO known_objects (name, type) VALUES (?, ?)",
|
||||||
|
(body.name, body.type),
|
||||||
|
)
|
||||||
|
known_object_id = cursor.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Must provide either known_object_id or both name and type",
|
||||||
|
)
|
||||||
|
|
||||||
|
# If event has an embedding, store it in the embeddings table
|
||||||
|
if event_row["embedding"] is not None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO embeddings (known_object_id, embedding) VALUES (?, ?)",
|
||||||
|
(known_object_id, event_row["embedding"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update event
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE events
|
||||||
|
SET match_status = 'known',
|
||||||
|
matched_object_id = ?,
|
||||||
|
match_confidence = 1.0
|
||||||
|
WHERE id = ?""",
|
||||||
|
(known_object_id, event_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"status": "ok", "matched_object_id": known_object_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_id}/dismiss")
|
||||||
|
async def dismiss_event(event_id: int):
|
||||||
|
"""Set event match_status to 'unknown'."""
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
cursor = conn.execute("SELECT id FROM events WHERE id = ?", (event_id,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE events SET match_status = 'unknown' WHERE id = ?",
|
||||||
|
(event_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Known objects API endpoints for frigate-notify v2."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from database import get_connection
|
||||||
|
from models import KnownObjectResponse, KnownObjectUpdate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/known-objects", tags=["known-objects"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_known_objects(
|
||||||
|
type: Optional[str] = Query(default=None, pattern="^(person|vehicle|pet)$"),
|
||||||
|
):
|
||||||
|
"""List all known objects with embedding_count. Optional type filter."""
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
ko.id, ko.name, ko.type, ko.action,
|
||||||
|
ko.created_at, ko.updated_at,
|
||||||
|
COUNT(e.id) AS embedding_count
|
||||||
|
FROM known_objects ko
|
||||||
|
LEFT JOIN embeddings e ON ko.id = e.known_object_id
|
||||||
|
"""
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if type:
|
||||||
|
query += " WHERE ko.type = ?"
|
||||||
|
params.append(type)
|
||||||
|
|
||||||
|
query += " GROUP BY ko.id ORDER BY ko.name"
|
||||||
|
|
||||||
|
cursor = conn.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"type": row["type"],
|
||||||
|
"action": row["action"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
"embedding_count": row["embedding_count"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{object_id}")
|
||||||
|
async def get_known_object(object_id: int):
|
||||||
|
"""Get a single known object with list of its embeddings (id, created_at only)."""
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id, name, type, action, created_at, updated_at FROM known_objects WHERE id = ?",
|
||||||
|
(object_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Known object not found")
|
||||||
|
|
||||||
|
# Get embeddings list
|
||||||
|
emb_cursor = conn.execute(
|
||||||
|
"SELECT id, created_at FROM embeddings WHERE known_object_id = ? ORDER BY created_at",
|
||||||
|
(object_id,),
|
||||||
|
)
|
||||||
|
embeddings = [
|
||||||
|
{"id": e["id"], "created_at": e["created_at"]} for e in emb_cursor.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"type": row["type"],
|
||||||
|
"action": row["action"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
"embeddings": embeddings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{object_id}/thumbnails/{embedding_id}")
|
||||||
|
async def get_embedding_thumbnail(object_id: int, embedding_id: int):
|
||||||
|
"""Return raw JPEG thumbnail from embeddings table."""
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT thumbnail FROM embeddings WHERE id = ? AND known_object_id = ?",
|
||||||
|
(embedding_id, object_id),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Embedding not found")
|
||||||
|
if row["thumbnail"] is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No thumbnail available")
|
||||||
|
|
||||||
|
return Response(content=row["thumbnail"], media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{object_id}")
|
||||||
|
async def update_known_object(object_id: int, body: KnownObjectUpdate):
|
||||||
|
"""Update name/type/action for a known object. Sets updated_at."""
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id FROM known_objects WHERE id = ?",
|
||||||
|
(object_id,),
|
||||||
|
)
|
||||||
|
if not cursor.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Known object not found")
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if body.name is not None:
|
||||||
|
updates.append("name = ?")
|
||||||
|
params.append(body.name)
|
||||||
|
if body.type is not None:
|
||||||
|
updates.append("type = ?")
|
||||||
|
params.append(body.type)
|
||||||
|
if body.action is not None:
|
||||||
|
updates.append("action = ?")
|
||||||
|
params.append(body.action)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||||
|
params.append(object_id)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE known_objects SET {', '.join(updates)} WHERE id = ?",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Return the updated object
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id, name, type, action, created_at, updated_at FROM known_objects WHERE id = ?",
|
||||||
|
(object_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
return KnownObjectResponse(
|
||||||
|
id=row["id"],
|
||||||
|
name=row["name"],
|
||||||
|
type=row["type"],
|
||||||
|
action=row["action"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{object_id}")
|
||||||
|
async def delete_known_object(object_id: int):
|
||||||
|
"""Delete a known object. Embeddings cascade delete automatically."""
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id FROM known_objects WHERE id = ?",
|
||||||
|
(object_id,),
|
||||||
|
)
|
||||||
|
if not cursor.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Known object not found")
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM known_objects WHERE id = ?", (object_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Settings and stats API endpoints for frigate-notify v2."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from database import get_connection
|
||||||
|
from models import StatsResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
async def get_settings():
|
||||||
|
"""Return all settings from DB plus runtime config values."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.execute("SELECT key, value FROM settings")
|
||||||
|
db_settings = {row["key"]: row["value"] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
# Merge with runtime config values
|
||||||
|
return {
|
||||||
|
**db_settings,
|
||||||
|
"recognition_threshold_high": config.RECOGNITION_THRESHOLD_HIGH,
|
||||||
|
"recognition_threshold_low": config.RECOGNITION_THRESHOLD_LOW,
|
||||||
|
"rate_limit_seconds": config.RATE_LIMIT_SECONDS,
|
||||||
|
"allowed_labels": list(config.ALLOWED_LABELS),
|
||||||
|
"min_score": config.MIN_SCORE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings")
|
||||||
|
async def update_settings(body: dict):
|
||||||
|
"""Accept dict body, upsert each key/value into settings table."""
|
||||||
|
conn = get_connection()
|
||||||
|
for key, value in body.items():
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
(key, str(value)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=StatsResponse)
|
||||||
|
async def get_stats():
|
||||||
|
"""Return event and object counts."""
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
||||||
|
known = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM events WHERE match_status = 'known'"
|
||||||
|
).fetchone()[0]
|
||||||
|
unknown = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM events WHERE match_status = 'unknown'"
|
||||||
|
).fetchone()[0]
|
||||||
|
unreviewed = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM events WHERE match_status = 'unreviewed'"
|
||||||
|
).fetchone()[0]
|
||||||
|
objects_count = conn.execute("SELECT COUNT(*) FROM known_objects").fetchone()[0]
|
||||||
|
today = date.today().isoformat()
|
||||||
|
events_today = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM events WHERE DATE(created_at) = ?",
|
||||||
|
(today,),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
return StatsResponse(
|
||||||
|
total_events=total,
|
||||||
|
known_matches=known,
|
||||||
|
unknown_events=unknown,
|
||||||
|
unreviewed=unreviewed,
|
||||||
|
known_objects_count=objects_count,
|
||||||
|
events_today=events_today,
|
||||||
|
)
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
"""API endpoint tests for frigate-notify v2."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from database import close_connection, get_connection, init_db, set_db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(db_path):
|
||||||
|
"""Create a TestClient with a temporary database.
|
||||||
|
|
||||||
|
Patches config.DB_PATH and mocks heavy dependencies (MQTT, anthropic,
|
||||||
|
matrix_client, vision) so that main.py can be imported without them.
|
||||||
|
"""
|
||||||
|
# Set DB path before anything imports the app
|
||||||
|
set_db_path(db_path)
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Mock modules that main.py imports at top level but aren't needed for API tests
|
||||||
|
mock_modules = {}
|
||||||
|
for mod_name in ("paho", "paho.mqtt", "paho.mqtt.client", "anthropic", "aiohttp"):
|
||||||
|
if mod_name not in sys.modules:
|
||||||
|
mock_modules[mod_name] = MagicMock()
|
||||||
|
sys.modules[mod_name] = mock_modules[mod_name]
|
||||||
|
|
||||||
|
# Ensure matrix_client and vision modules are available as mocks
|
||||||
|
if "matrix_client" not in sys.modules:
|
||||||
|
mock_modules["matrix_client"] = MagicMock()
|
||||||
|
sys.modules["matrix_client"] = mock_modules["matrix_client"]
|
||||||
|
if "vision" not in sys.modules:
|
||||||
|
mock_modules["vision"] = MagicMock()
|
||||||
|
sys.modules["vision"] = mock_modules["vision"]
|
||||||
|
|
||||||
|
# Patch config DB_PATH
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
original_db_path = config.DB_PATH
|
||||||
|
config.DB_PATH = db_path
|
||||||
|
|
||||||
|
# Now import the app (this will use the patched DB_PATH)
|
||||||
|
# We need to patch the lifespan to avoid starting MQTT
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
# Override the lifespan to just init DB without MQTT
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def test_lifespan(app):
|
||||||
|
set_db_path(db_path)
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
close_connection()
|
||||||
|
|
||||||
|
app.router.lifespan_context = test_lifespan
|
||||||
|
|
||||||
|
with TestClient(app) as tc:
|
||||||
|
yield tc
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
close_connection()
|
||||||
|
config.DB_PATH = original_db_path
|
||||||
|
|
||||||
|
# Remove mocked modules
|
||||||
|
for mod_name in mock_modules:
|
||||||
|
if mod_name in sys.modules and sys.modules[mod_name] is mock_modules[mod_name]:
|
||||||
|
del sys.modules[mod_name]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helper to insert test data ---
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_event(
|
||||||
|
frigate_event_id="evt-001",
|
||||||
|
camera="front_door",
|
||||||
|
label="person",
|
||||||
|
score=0.95,
|
||||||
|
snapshot=None,
|
||||||
|
embedding=None,
|
||||||
|
match_status="unreviewed",
|
||||||
|
matched_object_id=None,
|
||||||
|
ai_description=None,
|
||||||
|
):
|
||||||
|
"""Insert a test event directly into the database. Returns the event id."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""INSERT INTO events
|
||||||
|
(frigate_event_id, camera, label, score, snapshot, embedding,
|
||||||
|
match_status, matched_object_id, ai_description, notified)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)""",
|
||||||
|
(
|
||||||
|
frigate_event_id,
|
||||||
|
camera,
|
||||||
|
label,
|
||||||
|
score,
|
||||||
|
snapshot,
|
||||||
|
embedding,
|
||||||
|
match_status,
|
||||||
|
matched_object_id,
|
||||||
|
ai_description,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_known_object(name="John", type_="person", action="quiet"):
|
||||||
|
"""Insert a test known object directly into the database. Returns the object id."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO known_objects (name, type, action) VALUES (?, ?, ?)",
|
||||||
|
(name, type_, action),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
# --- Health endpoint ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealth:
|
||||||
|
def test_health_returns_200(self, client):
|
||||||
|
resp = client.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["version"] == "2.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Events endpoints ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventsAPI:
|
||||||
|
def test_list_events_empty(self, client):
|
||||||
|
resp = client.get("/api/events")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
def test_list_events_returns_events(self, client):
|
||||||
|
_insert_event(frigate_event_id="evt-list-1", camera="cam1", label="person")
|
||||||
|
_insert_event(frigate_event_id="evt-list-2", camera="cam2", label="car")
|
||||||
|
|
||||||
|
resp = client.get("/api/events")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
|
||||||
|
def test_list_events_filter_by_camera(self, client):
|
||||||
|
_insert_event(frigate_event_id="evt-cam1", camera="front_door")
|
||||||
|
_insert_event(frigate_event_id="evt-cam2", camera="back_yard")
|
||||||
|
|
||||||
|
resp = client.get("/api/events?camera=front_door")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["camera"] == "front_door"
|
||||||
|
|
||||||
|
def test_list_events_filter_by_status(self, client):
|
||||||
|
_insert_event(frigate_event_id="evt-s1", match_status="unreviewed")
|
||||||
|
_insert_event(frigate_event_id="evt-s2", match_status="known")
|
||||||
|
|
||||||
|
resp = client.get("/api/events?status=known")
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["match_status"] == "known"
|
||||||
|
|
||||||
|
def test_list_events_has_snapshot_field(self, client):
|
||||||
|
_insert_event(
|
||||||
|
frigate_event_id="evt-snap", snapshot=b"\xff\xd8\xff\xe0fake-jpeg"
|
||||||
|
)
|
||||||
|
_insert_event(frigate_event_id="evt-nosnap", snapshot=None)
|
||||||
|
|
||||||
|
resp = client.get("/api/events")
|
||||||
|
data = resp.json()
|
||||||
|
snap_event = next(e for e in data if e["frigate_event_id"] == "evt-snap")
|
||||||
|
nosnap_event = next(e for e in data if e["frigate_event_id"] == "evt-nosnap")
|
||||||
|
assert snap_event["has_snapshot"] is True
|
||||||
|
assert nosnap_event["has_snapshot"] is False
|
||||||
|
|
||||||
|
def test_list_events_with_matched_object_name(self, client):
|
||||||
|
obj_id = _insert_known_object(name="Alice")
|
||||||
|
_insert_event(
|
||||||
|
frigate_event_id="evt-matched",
|
||||||
|
match_status="known",
|
||||||
|
matched_object_id=obj_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/events")
|
||||||
|
data = resp.json()
|
||||||
|
assert data[0]["matched_object_name"] == "Alice"
|
||||||
|
|
||||||
|
def test_get_event_not_found(self, client):
|
||||||
|
resp = client.get("/api/events/99999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_get_event_detail(self, client):
|
||||||
|
event_id = _insert_event(frigate_event_id="evt-detail", camera="pool")
|
||||||
|
|
||||||
|
resp = client.get(f"/api/events/{event_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["frigate_event_id"] == "evt-detail"
|
||||||
|
assert data["camera"] == "pool"
|
||||||
|
|
||||||
|
def test_get_event_detail_includes_snapshot_base64(self, client):
|
||||||
|
jpeg_bytes = b"\xff\xd8\xff\xe0fake-jpeg-detail"
|
||||||
|
event_id = _insert_event(
|
||||||
|
frigate_event_id="evt-detail-snap", camera="pool", snapshot=jpeg_bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get(f"/api/events/{event_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["has_snapshot"] is True
|
||||||
|
assert data["snapshot_base64"] is not None
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
decoded = base64.b64decode(data["snapshot_base64"])
|
||||||
|
assert decoded == jpeg_bytes
|
||||||
|
|
||||||
|
def test_get_event_detail_no_snapshot_base64_when_missing(self, client):
|
||||||
|
event_id = _insert_event(
|
||||||
|
frigate_event_id="evt-detail-nosnap", camera="pool", snapshot=None
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get(f"/api/events/{event_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["has_snapshot"] is False
|
||||||
|
assert data["snapshot_base64"] is None
|
||||||
|
|
||||||
|
def test_get_event_snapshot(self, client):
|
||||||
|
jpeg_bytes = b"\xff\xd8\xff\xe0fake-jpeg-data"
|
||||||
|
event_id = _insert_event(
|
||||||
|
frigate_event_id="evt-snap-get", snapshot=jpeg_bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get(f"/api/events/{event_id}/snapshot")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "image/jpeg"
|
||||||
|
assert resp.content == jpeg_bytes
|
||||||
|
|
||||||
|
def test_get_event_snapshot_not_found(self, client):
|
||||||
|
resp = client.get("/api/events/99999/snapshot")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_get_event_snapshot_no_snapshot(self, client):
|
||||||
|
event_id = _insert_event(frigate_event_id="evt-nosnap2", snapshot=None)
|
||||||
|
|
||||||
|
resp = client.get(f"/api/events/{event_id}/snapshot")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_dismiss_event(self, client):
|
||||||
|
event_id = _insert_event(frigate_event_id="evt-dismiss")
|
||||||
|
|
||||||
|
resp = client.post(f"/api/events/{event_id}/dismiss")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify status changed
|
||||||
|
resp2 = client.get(f"/api/events/{event_id}")
|
||||||
|
assert resp2.json()["match_status"] == "unknown"
|
||||||
|
|
||||||
|
def test_dismiss_event_not_found(self, client):
|
||||||
|
resp = client.post("/api/events/99999/dismiss")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_tag_event_with_existing_object(self, client):
|
||||||
|
obj_id = _insert_known_object(name="Bob")
|
||||||
|
event_id = _insert_event(frigate_event_id="evt-tag1")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/events/{event_id}/tag",
|
||||||
|
json={"known_object_id": obj_id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["matched_object_id"] == obj_id
|
||||||
|
|
||||||
|
# Verify event updated
|
||||||
|
resp2 = client.get(f"/api/events/{event_id}")
|
||||||
|
event_data = resp2.json()
|
||||||
|
assert event_data["match_status"] == "known"
|
||||||
|
assert event_data["matched_object_id"] == obj_id
|
||||||
|
assert event_data["match_confidence"] == 1.0
|
||||||
|
|
||||||
|
def test_tag_event_creates_new_object(self, client):
|
||||||
|
event_id = _insert_event(frigate_event_id="evt-tag2")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/events/{event_id}/tag",
|
||||||
|
json={"name": "NewPerson", "type": "person"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
new_obj_id = resp.json()["matched_object_id"]
|
||||||
|
|
||||||
|
# Verify the known object was created
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT name, type FROM known_objects WHERE id = ?", (new_obj_id,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
assert row["name"] == "NewPerson"
|
||||||
|
assert row["type"] == "person"
|
||||||
|
|
||||||
|
def test_tag_event_stores_embedding(self, client):
|
||||||
|
fake_embedding = b"\x00\x01\x02\x03\x04"
|
||||||
|
obj_id = _insert_known_object(name="EmbedTest")
|
||||||
|
event_id = _insert_event(
|
||||||
|
frigate_event_id="evt-embed", embedding=fake_embedding
|
||||||
|
)
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
f"/api/events/{event_id}/tag",
|
||||||
|
json={"known_object_id": obj_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify embedding was stored
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT embedding FROM embeddings WHERE known_object_id = ?", (obj_id,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert bytes(row["embedding"]) == fake_embedding
|
||||||
|
|
||||||
|
def test_tag_event_bad_request(self, client):
|
||||||
|
event_id = _insert_event(frigate_event_id="evt-bad-tag")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/events/{event_id}/tag",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_tag_event_not_found(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/events/99999/tag",
|
||||||
|
json={"name": "Nobody", "type": "person"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_tag_event_known_object_not_found(self, client):
|
||||||
|
event_id = _insert_event(frigate_event_id="evt-bad-obj")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/events/{event_id}/tag",
|
||||||
|
json={"known_object_id": 99999},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_list_events_pagination(self, client):
|
||||||
|
for i in range(5):
|
||||||
|
_insert_event(frigate_event_id=f"evt-page-{i}", camera="cam1")
|
||||||
|
|
||||||
|
resp = client.get("/api/events?limit=2&offset=0")
|
||||||
|
assert len(resp.json()) == 2
|
||||||
|
|
||||||
|
resp2 = client.get("/api/events?limit=2&offset=2")
|
||||||
|
assert len(resp2.json()) == 2
|
||||||
|
|
||||||
|
resp3 = client.get("/api/events?limit=2&offset=4")
|
||||||
|
assert len(resp3.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- Known Objects endpoints ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestKnownObjectsAPI:
|
||||||
|
def test_list_known_objects_empty(self, client):
|
||||||
|
resp = client.get("/api/known-objects")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
def test_list_known_objects(self, client):
|
||||||
|
_insert_known_object(name="Alice", type_="person")
|
||||||
|
_insert_known_object(name="Rex", type_="pet")
|
||||||
|
|
||||||
|
resp = client.get("/api/known-objects")
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
# Should include embedding_count
|
||||||
|
assert all("embedding_count" in obj for obj in data)
|
||||||
|
|
||||||
|
def test_list_known_objects_filter_by_type(self, client):
|
||||||
|
_insert_known_object(name="Alice", type_="person")
|
||||||
|
_insert_known_object(name="Rex", type_="pet")
|
||||||
|
|
||||||
|
resp = client.get("/api/known-objects?type=person")
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["name"] == "Alice"
|
||||||
|
|
||||||
|
def test_get_known_object(self, client):
|
||||||
|
obj_id = _insert_known_object(name="Charlie")
|
||||||
|
|
||||||
|
resp = client.get(f"/api/known-objects/{obj_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "Charlie"
|
||||||
|
assert "embeddings" in data
|
||||||
|
assert data["embeddings"] == []
|
||||||
|
|
||||||
|
def test_get_known_object_not_found(self, client):
|
||||||
|
resp = client.get("/api/known-objects/99999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_update_known_object(self, client):
|
||||||
|
obj_id = _insert_known_object(name="OldName", type_="person", action="quiet")
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/known-objects/{obj_id}",
|
||||||
|
json={"name": "NewName", "action": "alert"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "NewName"
|
||||||
|
assert data["action"] == "alert"
|
||||||
|
assert data["type"] == "person" # unchanged
|
||||||
|
|
||||||
|
def test_update_known_object_not_found(self, client):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/known-objects/99999",
|
||||||
|
json={"name": "Nobody"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_update_known_object_no_fields(self, client):
|
||||||
|
obj_id = _insert_known_object(name="NoChange")
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/known-objects/{obj_id}",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_delete_known_object(self, client):
|
||||||
|
obj_id = _insert_known_object(name="DeleteMe")
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/known-objects/{obj_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify deleted
|
||||||
|
resp2 = client.get(f"/api/known-objects/{obj_id}")
|
||||||
|
assert resp2.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_known_object_not_found(self, client):
|
||||||
|
resp = client.delete("/api/known-objects/99999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_get_embedding_thumbnail(self, client):
|
||||||
|
obj_id = _insert_known_object(name="ThumbTest")
|
||||||
|
thumbnail_data = b"\xff\xd8\xff\xe0thumb-jpeg"
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO embeddings (known_object_id, embedding, thumbnail) VALUES (?, ?, ?)",
|
||||||
|
(obj_id, b"\x00\x01", thumbnail_data),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
emb_id = conn.execute(
|
||||||
|
"SELECT id FROM embeddings WHERE known_object_id = ?", (obj_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
resp = client.get(f"/api/known-objects/{obj_id}/thumbnails/{emb_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "image/jpeg"
|
||||||
|
assert resp.content == thumbnail_data
|
||||||
|
|
||||||
|
def test_get_embedding_thumbnail_not_found(self, client):
|
||||||
|
resp = client.get("/api/known-objects/99999/thumbnails/99999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_known_objects_embedding_count(self, client):
|
||||||
|
obj_id = _insert_known_object(name="CountTest")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
for i in range(3):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO embeddings (known_object_id, embedding) VALUES (?, ?)",
|
||||||
|
(obj_id, b"\x00"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/known-objects")
|
||||||
|
data = resp.json()
|
||||||
|
obj = next(o for o in data if o["name"] == "CountTest")
|
||||||
|
assert obj["embedding_count"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# --- Settings endpoints ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsAPI:
|
||||||
|
def test_get_settings_defaults(self, client):
|
||||||
|
resp = client.get("/api/settings")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# DB defaults
|
||||||
|
assert data["vision_backend"] == "haiku"
|
||||||
|
assert data["recognition_enabled"] == "false"
|
||||||
|
assert data["event_retention_days"] == "90"
|
||||||
|
# Runtime config values
|
||||||
|
assert "recognition_threshold_high" in data
|
||||||
|
assert "recognition_threshold_low" in data
|
||||||
|
assert "rate_limit_seconds" in data
|
||||||
|
assert "allowed_labels" in data
|
||||||
|
assert "min_score" in data
|
||||||
|
|
||||||
|
def test_update_settings(self, client):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/settings",
|
||||||
|
json={"vision_backend": "ollama", "event_retention_days": "30"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify changes
|
||||||
|
resp2 = client.get("/api/settings")
|
||||||
|
data = resp2.json()
|
||||||
|
assert data["vision_backend"] == "ollama"
|
||||||
|
assert data["event_retention_days"] == "30"
|
||||||
|
|
||||||
|
def test_update_settings_upsert_new_key(self, client):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/settings",
|
||||||
|
json={"custom_key": "custom_value"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp2 = client.get("/api/settings")
|
||||||
|
data = resp2.json()
|
||||||
|
assert data["custom_key"] == "custom_value"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stats endpoint ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatsAPI:
|
||||||
|
def test_stats_returns_zeros(self, client):
|
||||||
|
resp = client.get("/api/stats")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total_events"] == 0
|
||||||
|
assert data["known_matches"] == 0
|
||||||
|
assert data["unknown_events"] == 0
|
||||||
|
assert data["unreviewed"] == 0
|
||||||
|
assert data["known_objects_count"] == 0
|
||||||
|
assert data["events_today"] == 0
|
||||||
|
|
||||||
|
def test_stats_counts_events(self, client):
|
||||||
|
_insert_event(frigate_event_id="evt-stat-1", match_status="unreviewed")
|
||||||
|
_insert_event(frigate_event_id="evt-stat-2", match_status="known")
|
||||||
|
_insert_event(frigate_event_id="evt-stat-3", match_status="unknown")
|
||||||
|
_insert_known_object(name="StatObj")
|
||||||
|
|
||||||
|
resp = client.get("/api/stats")
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total_events"] == 3
|
||||||
|
assert data["known_matches"] == 1
|
||||||
|
assert data["unknown_events"] == 1
|
||||||
|
assert data["unreviewed"] == 1
|
||||||
|
assert data["known_objects_count"] == 1
|
||||||
|
assert data["events_today"] == 3 # All inserted today
|
||||||
|
|
||||||
|
|
||||||
|
# --- Full tag event flow ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagEventFlow:
|
||||||
|
def test_full_tag_flow(self, client):
|
||||||
|
"""Create event in DB, tag it with a new object, verify everything."""
|
||||||
|
# 1. Create an event with an embedding
|
||||||
|
fake_embedding = b"\x00\x01\x02\x03"
|
||||||
|
event_id = _insert_event(
|
||||||
|
frigate_event_id="evt-flow-1",
|
||||||
|
camera="driveway",
|
||||||
|
label="person",
|
||||||
|
score=0.92,
|
||||||
|
embedding=fake_embedding,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Verify it shows as unreviewed
|
||||||
|
resp = client.get(f"/api/events/{event_id}")
|
||||||
|
assert resp.json()["match_status"] == "unreviewed"
|
||||||
|
|
||||||
|
# 3. Tag the event with a new person
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/events/{event_id}/tag",
|
||||||
|
json={"name": "FlowPerson", "type": "person"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
new_obj_id = resp.json()["matched_object_id"]
|
||||||
|
|
||||||
|
# 4. Verify event is now 'known'
|
||||||
|
resp = client.get(f"/api/events/{event_id}")
|
||||||
|
event_data = resp.json()
|
||||||
|
assert event_data["match_status"] == "known"
|
||||||
|
assert event_data["matched_object_id"] == new_obj_id
|
||||||
|
assert event_data["matched_object_name"] == "FlowPerson"
|
||||||
|
assert event_data["match_confidence"] == 1.0
|
||||||
|
|
||||||
|
# 5. Verify known object was created with embedding
|
||||||
|
resp = client.get(f"/api/known-objects/{new_obj_id}")
|
||||||
|
obj_data = resp.json()
|
||||||
|
assert obj_data["name"] == "FlowPerson"
|
||||||
|
assert obj_data["type"] == "person"
|
||||||
|
assert len(obj_data["embeddings"]) == 1
|
||||||
|
|
||||||
|
# 6. Verify stats updated
|
||||||
|
resp = client.get("/api/stats")
|
||||||
|
stats = resp.json()
|
||||||
|
assert stats["total_events"] == 1
|
||||||
|
assert stats["known_matches"] == 1
|
||||||
|
assert stats["known_objects_count"] == 1
|
||||||
Reference in New Issue
Block a user