feat: SQLite persistence, non-blocking inference, modular frontend
Backend:
- Persist alert events and robot delivery counters in SQLite (data/app.db,
YOLO_DB_PATH override); EventStore keeps its interface, DeliveryStatsStore
uses read/write-through UPSERT; in-memory fallback remains for standalone
AlertManager use
- Serve /api/detect from a sync endpoint and serialize YOLO inference with a
lock, so concurrent requests no longer block the event loop
- Unify single-image and session detection: requests without session_id
share the fixed single-image session and run full frame confirmation
- Store UTC Z-suffixed timestamps for sortable string comparison; close
executor and database on shutdown via FastAPI lifespan
Frontend:
- Split app.js into ES modules (main.js + modules/{dom,api,ui,charts,store}
+ views/{dashboard,inspection,events,robots,robotCards}), no build step
- Escape all server data interpolated into innerHTML; guard missing
event.classes; replace lazy element ID list with memoized qs()
- Remove hardcoded fake stats (trend badge, device donut segment)
Docs:
- Rewrite README: accurate weight policy (only trained best.pt committed,
no Git LFS), SQLite persistence, single-worker note, data asset inventory
- Align models/pretrained/README.md with actual files; add YOLO_DB_PATH to
.env.example; add persistence unit tests; ruff clean
This commit is contained in:
@@ -11,7 +11,6 @@ import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
@@ -19,6 +18,8 @@ from urllib.request import Request, urlopen
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .storage import DeliveryStatsStore, utc_now_iso
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
ALERT_CLASSES = ("fire", "smoke")
|
||||
CHANNEL_NAMES = {"wechat": "企业微信", "feishu": "飞书"}
|
||||
@@ -45,6 +46,7 @@ class AlertManager:
|
||||
confirm_frames: int = 3,
|
||||
cooldown_seconds: float = 60.0,
|
||||
session_ttl_seconds: float = 3600.0,
|
||||
stats_store: DeliveryStatsStore | None = None,
|
||||
) -> None:
|
||||
self.wechat_webhook_url = (
|
||||
wechat_webhook_url
|
||||
@@ -58,6 +60,7 @@ class AlertManager:
|
||||
self.confirm_frames = max(1, confirm_frames)
|
||||
self.cooldown_seconds = max(0.0, cooldown_seconds)
|
||||
self.session_ttl_seconds = max(60.0, session_ttl_seconds)
|
||||
self.stats_store = stats_store
|
||||
self._states: dict[str, SessionState] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._delivery_stats = {
|
||||
@@ -92,11 +95,27 @@ class AlertManager:
|
||||
@property
|
||||
def channel_status(self) -> list[dict[str, Any]]:
|
||||
configured = self.channels
|
||||
with self._lock:
|
||||
if self.stats_store is not None:
|
||||
saved = self.stats_store.get_all()
|
||||
stats = {
|
||||
channel: dict(values)
|
||||
for channel, values in self._delivery_stats.items()
|
||||
channel: {
|
||||
"delivered": 0,
|
||||
"failed": 0,
|
||||
"tests": 0,
|
||||
"last_status": "idle",
|
||||
"last_delivery_at": None,
|
||||
"last_test_at": None,
|
||||
"last_error": None,
|
||||
**saved.get(channel, {}),
|
||||
}
|
||||
for channel in CHANNEL_NAMES
|
||||
}
|
||||
else:
|
||||
with self._lock:
|
||||
stats = {
|
||||
channel: dict(values)
|
||||
for channel, values in self._delivery_stats.items()
|
||||
}
|
||||
return [
|
||||
{
|
||||
"id": channel,
|
||||
@@ -191,6 +210,10 @@ class AlertManager:
|
||||
with self._lock:
|
||||
self._states.pop(session_id, None)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Shut down the alert dispatch executor (call on service shutdown)."""
|
||||
self._executor.shutdown(wait=True, cancel_futures=True)
|
||||
|
||||
def _prune_sessions(self, now: float) -> None:
|
||||
expired = [
|
||||
session_id
|
||||
@@ -230,7 +253,15 @@ class AlertManager:
|
||||
error: str | None = None,
|
||||
test: bool = False,
|
||||
) -> None:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
if self.stats_store is not None:
|
||||
self.stats_store.record(
|
||||
channel,
|
||||
success=success,
|
||||
error=error,
|
||||
test=test,
|
||||
)
|
||||
return
|
||||
now = utc_now_iso()
|
||||
with self._lock:
|
||||
stats = self._delivery_stats[channel]
|
||||
stats["last_status"] = "success" if success else "failed"
|
||||
@@ -386,7 +417,7 @@ def alert_summary(
|
||||
|
||||
|
||||
def feishu_signature(timestamp: str, secret: str) -> str:
|
||||
string_to_sign = f"{timestamp}\n{secret}".encode("utf-8")
|
||||
string_to_sign = f"{timestamp}\n{secret}".encode()
|
||||
digest = hmac.new(string_to_sign, digestmod=hashlib.sha256).digest()
|
||||
return base64.b64encode(digest).decode("ascii")
|
||||
|
||||
@@ -429,7 +460,7 @@ def annotate_image(
|
||||
class_name = str(detection.get("class", "target"))
|
||||
confidence = float(detection.get("confidence", 0.0))
|
||||
color = colors.get(class_name, "#ffd166")
|
||||
coordinates = tuple(int(round(value)) for value in box)
|
||||
coordinates = tuple(round(value) for value in box)
|
||||
draw.rectangle(coordinates, outline=color, width=4)
|
||||
draw.text(
|
||||
(coordinates[0] + 4, max(0, coordinates[1] - 16)),
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import json
|
||||
import uuid
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from .storage import Database, utc_now_iso
|
||||
|
||||
EVENT_STATUSES = {"pending", "acknowledged", "resolved"}
|
||||
|
||||
|
||||
def _local_today_utc_boundary() -> str:
|
||||
"""UTC Z-format timestamp of local midnight, for 'today' comparisons."""
|
||||
local_midnight = datetime.now().astimezone().replace(
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
return (
|
||||
local_midnight.astimezone(UTC)
|
||||
.isoformat(timespec="seconds")
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
class EventStore:
|
||||
def __init__(self, max_events: int = 500) -> None:
|
||||
self._events: deque[dict[str, Any]] = deque(maxlen=max_events)
|
||||
self._lock = threading.Lock()
|
||||
"""Persistent alert event store backed by a single SQLite table.
|
||||
|
||||
Keeps at most ``max_events`` rows (newest first, ordered by rowid so
|
||||
events created within the same second keep a stable insertion order).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Database | None = None,
|
||||
max_events: int = 500,
|
||||
) -> None:
|
||||
self._db = db or Database(":memory:")
|
||||
self._max_events = max(1, max_events)
|
||||
|
||||
def create(
|
||||
self,
|
||||
@@ -21,7 +47,6 @@ class EventStore:
|
||||
detections: list[dict[str, Any]],
|
||||
notification_channels: dict[str, bool],
|
||||
) -> dict[str, Any]:
|
||||
now = datetime.now().astimezone()
|
||||
confidences = [
|
||||
float(detection.get("confidence", 0.0))
|
||||
for detection in detections
|
||||
@@ -29,7 +54,7 @@ class EventStore:
|
||||
]
|
||||
event = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"created_at": now.isoformat(timespec="seconds"),
|
||||
"created_at": utc_now_iso(),
|
||||
"session_id": session_id,
|
||||
"classes": classes,
|
||||
"max_confidence": round(max(confidences, default=0.0), 6),
|
||||
@@ -38,17 +63,44 @@ class EventStore:
|
||||
"notification_channels": notification_channels,
|
||||
"handled_at": None,
|
||||
}
|
||||
with self._lock:
|
||||
self._events.appendleft(event)
|
||||
return dict(event)
|
||||
self._db.execute(
|
||||
"""
|
||||
INSERT INTO events (
|
||||
id, created_at, session_id, classes, max_confidence,
|
||||
detection_count, status, notification_channels, handled_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
event["id"],
|
||||
event["created_at"],
|
||||
session_id,
|
||||
json.dumps(classes, ensure_ascii=False),
|
||||
event["max_confidence"],
|
||||
event["detection_count"],
|
||||
event["status"],
|
||||
json.dumps(notification_channels, ensure_ascii=False),
|
||||
None,
|
||||
),
|
||||
)
|
||||
self._db.execute(
|
||||
"""
|
||||
DELETE FROM events WHERE id IN (
|
||||
SELECT id FROM events ORDER BY rowid DESC LIMIT -1 OFFSET ?
|
||||
)
|
||||
""",
|
||||
(self._max_events,),
|
||||
)
|
||||
return event
|
||||
|
||||
def list(
|
||||
self,
|
||||
limit: int = 100,
|
||||
status: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
events = [dict(event) for event in self._events]
|
||||
rows = self._db.execute(
|
||||
"SELECT * FROM events ORDER BY rowid DESC",
|
||||
).fetchall()
|
||||
events = [self._to_dict(row) for row in rows]
|
||||
if status:
|
||||
events = [event for event in events if event["status"] == status]
|
||||
return events[: max(1, min(limit, 500))]
|
||||
@@ -56,42 +108,57 @@ class EventStore:
|
||||
def update(self, event_id: str, status: str) -> dict[str, Any] | None:
|
||||
if status not in EVENT_STATUSES:
|
||||
raise ValueError(f"Unsupported event status: {status}")
|
||||
with self._lock:
|
||||
for event in self._events:
|
||||
if event["id"] != event_id:
|
||||
continue
|
||||
event["status"] = status
|
||||
event["handled_at"] = (
|
||||
None
|
||||
if status == "pending"
|
||||
else datetime.now().astimezone().isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
)
|
||||
return dict(event)
|
||||
return None
|
||||
cursor = self._db.execute(
|
||||
"""
|
||||
UPDATE events SET status = ?, handled_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, None if status == "pending" else utc_now_iso(), event_id),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
return None
|
||||
row = self._db.execute(
|
||||
"SELECT * FROM events WHERE id = ?",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
return self._to_dict(row)
|
||||
|
||||
def summary(self) -> dict[str, int]:
|
||||
today = datetime.now().astimezone().date()
|
||||
with self._lock:
|
||||
events = [dict(event) for event in self._events]
|
||||
today_events = [
|
||||
event
|
||||
for event in events
|
||||
if datetime.fromisoformat(event["created_at"]).date() == today
|
||||
]
|
||||
rows = self._db.execute(
|
||||
"SELECT created_at, classes, status FROM events",
|
||||
).fetchall()
|
||||
today_boundary = _local_today_utc_boundary()
|
||||
today_events = 0
|
||||
counts = {status: 0 for status in EVENT_STATUSES}
|
||||
fire = 0
|
||||
smoke = 0
|
||||
for row in rows:
|
||||
counts[row["status"]] += 1
|
||||
classes = json.loads(row["classes"])
|
||||
if row["created_at"] >= today_boundary:
|
||||
today_events += 1
|
||||
fire += "fire" in classes
|
||||
smoke += "smoke" in classes
|
||||
return {
|
||||
"total": len(events),
|
||||
"today": len(today_events),
|
||||
"pending": sum(
|
||||
event["status"] == "pending" for event in events
|
||||
),
|
||||
"acknowledged": sum(
|
||||
event["status"] == "acknowledged" for event in events
|
||||
),
|
||||
"resolved": sum(
|
||||
event["status"] == "resolved" for event in events
|
||||
),
|
||||
"fire": sum("fire" in event["classes"] for event in events),
|
||||
"smoke": sum("smoke" in event["classes"] for event in events),
|
||||
"total": len(rows),
|
||||
"today": today_events,
|
||||
"pending": counts["pending"],
|
||||
"acknowledged": counts["acknowledged"],
|
||||
"resolved": counts["resolved"],
|
||||
"fire": fire,
|
||||
"smoke": smoke,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"created_at": row["created_at"],
|
||||
"session_id": row["session_id"],
|
||||
"classes": json.loads(row["classes"]),
|
||||
"max_confidence": row["max_confidence"],
|
||||
"detection_count": row["detection_count"],
|
||||
"status": row["status"],
|
||||
"notification_channels": json.loads(row["notification_channels"]),
|
||||
"handled_at": row["handled_at"],
|
||||
}
|
||||
|
||||
@@ -2,21 +2,25 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel
|
||||
from ultralytics import YOLO
|
||||
|
||||
from .alerting import AlertManager
|
||||
from .events import EVENT_STATUSES, EventStore
|
||||
from .storage import Database, DeliveryStatsStore
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
FRONTEND_DIR = PROJECT_ROOT / "frontend"
|
||||
@@ -30,17 +34,37 @@ IOU = float(os.getenv("YOLO_IOU", "0.45"))
|
||||
MAX_UPLOAD_BYTES = 15 * 1024 * 1024
|
||||
MAX_IMAGE_PIXELS = int(os.getenv("YOLO_MAX_IMAGE_PIXELS", "25000000"))
|
||||
CLASS_NAMES = {0: "smoke", 1: "fire"}
|
||||
DB_PATH = Path(
|
||||
os.getenv("YOLO_DB_PATH", str(PROJECT_ROOT / "data" / "app.db"))
|
||||
).expanduser().resolve()
|
||||
DB = Database(DB_PATH)
|
||||
EVENT_STORE = EventStore(db=DB)
|
||||
ALERT_MANAGER = AlertManager(
|
||||
confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")),
|
||||
cooldown_seconds=float(os.getenv("ALERT_COOLDOWN_SECONDS", "60")),
|
||||
stats_store=DeliveryStatsStore(DB),
|
||||
)
|
||||
EVENT_STORE = EventStore()
|
||||
# Ultralytics models are not thread-safe: serialize inference so concurrent
|
||||
# /api/detect requests from the FastAPI threadpool cannot race each other.
|
||||
INFERENCE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class EventStatusUpdate(BaseModel):
|
||||
status: str
|
||||
|
||||
app = FastAPI(title="Smoke Fire Detector API", version="0.1.0")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
yield
|
||||
ALERT_MANAGER.close()
|
||||
DB.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Smoke Fire Detector API",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -77,14 +101,15 @@ def validate_image(payload: bytes) -> Image.Image:
|
||||
def predict_image(image: Image.Image) -> dict[str, Any]:
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
result = get_model().predict(
|
||||
source=image,
|
||||
conf=CONFIDENCE,
|
||||
iou=IOU,
|
||||
imgsz=IMAGE_SIZE,
|
||||
device=DEVICE,
|
||||
verbose=False,
|
||||
)[0]
|
||||
with INFERENCE_LOCK:
|
||||
result = get_model().predict(
|
||||
source=image,
|
||||
conf=CONFIDENCE,
|
||||
iou=IOU,
|
||||
imgsz=IMAGE_SIZE,
|
||||
device=DEVICE,
|
||||
verbose=False,
|
||||
)[0]
|
||||
except FileNotFoundError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error)) from error
|
||||
except Exception as error:
|
||||
@@ -118,30 +143,25 @@ def health() -> dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/api/detect")
|
||||
async def detect(
|
||||
file: UploadFile = File(...),
|
||||
def detect(
|
||||
file: UploadFile = File(...), # noqa: B008 — FastAPI dependency idiom
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = await file.read(MAX_UPLOAD_BYTES + 1)
|
||||
payload = file.file.read(MAX_UPLOAD_BYTES + 1)
|
||||
image = validate_image(payload)
|
||||
result = predict_image(image)
|
||||
result["alert"] = (
|
||||
ALERT_MANAGER.evaluate(
|
||||
session_id,
|
||||
result["detections"],
|
||||
image,
|
||||
)
|
||||
if session_id
|
||||
else {
|
||||
"triggered": False,
|
||||
"classes": [],
|
||||
"notification_enabled": ALERT_MANAGER.enabled,
|
||||
"notification_channels": ALERT_MANAGER.channels,
|
||||
}
|
||||
# Requests without a session share the fixed "single-image" session, so
|
||||
# the consecutive-frame confirmation works there as well. Reset it with
|
||||
# DELETE /api/sessions/single-image.
|
||||
active_session = session_id or "single-image"
|
||||
result["alert"] = ALERT_MANAGER.evaluate(
|
||||
active_session,
|
||||
result["detections"],
|
||||
image,
|
||||
)
|
||||
if result["alert"]["triggered"]:
|
||||
result["event"] = EVENT_STORE.create(
|
||||
session_id=session_id or "single-image",
|
||||
session_id=active_session,
|
||||
classes=result["alert"]["classes"],
|
||||
detections=result["detections"],
|
||||
notification_channels=result["alert"].get(
|
||||
|
||||
137
backend/storage.py
Normal file
137
backend/storage.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
classes TEXT NOT NULL,
|
||||
max_confidence REAL NOT NULL,
|
||||
detection_count INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
notification_channels TEXT NOT NULL,
|
||||
handled_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS delivery_stats (
|
||||
channel TEXT PRIMARY KEY,
|
||||
delivered INTEGER NOT NULL DEFAULT 0,
|
||||
failed INTEGER NOT NULL DEFAULT 0,
|
||||
tests INTEGER NOT NULL DEFAULT 0,
|
||||
last_status TEXT NOT NULL DEFAULT 'idle',
|
||||
last_delivery_at TEXT,
|
||||
last_test_at TEXT,
|
||||
last_error TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
"""Current UTC time as a sortable ISO string with a Z suffix."""
|
||||
return (
|
||||
datetime.now(UTC)
|
||||
.isoformat(timespec="seconds")
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
class Database:
|
||||
"""Single shared SQLite connection guarded by a lock.
|
||||
|
||||
The API service is low-write and multi-threaded (FastAPI threadpool,
|
||||
alert dispatch executor), so one connection with ``check_same_thread=False``
|
||||
plus a lock is simpler and safer on Windows than per-operation connections.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
resolved = Path(path)
|
||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._connection = sqlite3.connect(
|
||||
str(resolved),
|
||||
check_same_thread=False,
|
||||
)
|
||||
self._connection.row_factory = sqlite3.Row
|
||||
self._lock = threading.Lock()
|
||||
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||
self._connection.execute("PRAGMA busy_timeout=5000")
|
||||
self._connection.execute("PRAGMA synchronous=NORMAL")
|
||||
self._connection.executescript(SCHEMA)
|
||||
self._connection.commit()
|
||||
|
||||
def execute(
|
||||
self,
|
||||
statement: str,
|
||||
parameters: tuple[Any, ...] = (),
|
||||
) -> sqlite3.Cursor:
|
||||
with self._lock:
|
||||
cursor = self._connection.execute(statement, parameters)
|
||||
self._connection.commit()
|
||||
return cursor
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._connection.close()
|
||||
|
||||
|
||||
class DeliveryStatsStore:
|
||||
"""Persistent per-channel delivery counters (read-through / write-through).
|
||||
|
||||
Each ``record`` call applies an atomic UPSERT carrying 0/1 increments and
|
||||
the new ``last_*`` values, so there is no in-memory copy to drift from disk.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Database) -> None:
|
||||
self._db = db
|
||||
|
||||
def get_all(self) -> dict[str, dict[str, Any]]:
|
||||
rows = self._db.execute(
|
||||
"SELECT * FROM delivery_stats",
|
||||
).fetchall()
|
||||
return {row["channel"]: dict(row) for row in rows}
|
||||
|
||||
def record(
|
||||
self,
|
||||
channel: str,
|
||||
*,
|
||||
success: bool,
|
||||
error: str | None = None,
|
||||
test: bool = False,
|
||||
) -> None:
|
||||
now = utc_now_iso()
|
||||
self._db.execute(
|
||||
"""
|
||||
INSERT INTO delivery_stats (
|
||||
channel, delivered, failed, tests, last_status,
|
||||
last_delivery_at, last_test_at, last_error
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(channel) DO UPDATE SET
|
||||
delivered = delivered + excluded.delivered,
|
||||
failed = failed + excluded.failed,
|
||||
tests = tests + excluded.tests,
|
||||
last_status = excluded.last_status,
|
||||
last_delivery_at = COALESCE(
|
||||
excluded.last_delivery_at,
|
||||
delivery_stats.last_delivery_at
|
||||
),
|
||||
last_test_at = COALESCE(
|
||||
excluded.last_test_at,
|
||||
delivery_stats.last_test_at
|
||||
),
|
||||
last_error = excluded.last_error
|
||||
""",
|
||||
(
|
||||
channel,
|
||||
1 if success else 0,
|
||||
0 if success else 1,
|
||||
1 if test else 0,
|
||||
"success" if success else "failed",
|
||||
now if not test else None,
|
||||
now if test else None,
|
||||
None if success else error,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user