🚧 Add message delete
This commit is contained in:
+246
@@ -754,6 +754,144 @@ class DeviceRenameStore:
|
||||
conn.close()
|
||||
|
||||
|
||||
class MessageRetentionPolicy:
|
||||
"""Soft-delete retention for messages: "deleting" MOVES rows into
|
||||
mqtt_messages_retention (same id), restorable until expires_at, purged
|
||||
afterwards. All moves are single atomic CTE statements — an id can
|
||||
never exist in both tables. Expired rows are purged LAZILY on every
|
||||
read/write below (no scheduler needed; the expires_at index makes it a
|
||||
cheap indexed delete)."""
|
||||
|
||||
def __init__(self, dsn: str, retention_days: int = 30) -> None:
|
||||
self._dsn = dsn
|
||||
self._retention_days = retention_days
|
||||
|
||||
def _purge_expired(self, cur: psycopg.Cursor) -> None:
|
||||
cur.execute("DELETE FROM mqtt_messages_retention WHERE expires_at < now()")
|
||||
|
||||
def delete(self, ids: list[int], deleted_by: str | None) -> int:
|
||||
"""Move rows out of mqtt_messages into retention. Unknown ids are
|
||||
skipped (a selection can go stale under live traffic). Returns the
|
||||
number of rows moved."""
|
||||
conn = psycopg.connect(self._dsn, autocommit=True)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
self._purge_expired(cur)
|
||||
# single atomic move: DELETE ... RETURNING feeds the INSERT
|
||||
cur.execute(
|
||||
"""
|
||||
WITH moved AS (
|
||||
DELETE FROM mqtt_messages
|
||||
WHERE id = ANY(%s)
|
||||
RETURNING id, received_at, topic, imei, device,
|
||||
payload, payload_time
|
||||
), ins AS (
|
||||
INSERT INTO mqtt_messages_retention
|
||||
(id, received_at, topic, imei, device, payload,
|
||||
payload_time, deleted_by, expires_at)
|
||||
SELECT id, received_at, topic, imei, device, payload,
|
||||
payload_time, %s,
|
||||
now() + make_interval(days => %s)
|
||||
FROM moved
|
||||
RETURNING id
|
||||
)
|
||||
SELECT count(*) FROM ins
|
||||
""",
|
||||
(ids, deleted_by, self._retention_days),
|
||||
)
|
||||
return cur.fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def restore(self, ids: list[int]) -> int:
|
||||
"""Move retained rows back into mqtt_messages with their ORIGINAL
|
||||
ids. Unknown ids are skipped. A plain INSERT (no ON CONFLICT): an
|
||||
id collision would mean a genuine anomaly and should fail loudly."""
|
||||
conn = psycopg.connect(self._dsn, autocommit=True)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
self._purge_expired(cur)
|
||||
cur.execute(
|
||||
"""
|
||||
WITH moved AS (
|
||||
DELETE FROM mqtt_messages_retention
|
||||
WHERE id = ANY(%s)
|
||||
RETURNING id, received_at, topic, imei, device,
|
||||
payload, payload_time
|
||||
), ins AS (
|
||||
INSERT INTO mqtt_messages
|
||||
(id, received_at, topic, imei, device, payload,
|
||||
payload_time)
|
||||
SELECT id, received_at, topic, imei, device, payload,
|
||||
payload_time
|
||||
FROM moved
|
||||
RETURNING id
|
||||
)
|
||||
SELECT count(*) FROM ins
|
||||
""",
|
||||
(ids,),
|
||||
)
|
||||
return cur.fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def count(self) -> int:
|
||||
"""Number of retained rows (expired ones purged first)."""
|
||||
# autocommit: the purge is a write too - on a plain connection it
|
||||
# would roll back on close (the count would still be correct within
|
||||
# the connection, but the expired rows would survive).
|
||||
conn = psycopg.connect(self._dsn, autocommit=True)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
self._purge_expired(cur)
|
||||
cur.execute("SELECT count(*) FROM mqtt_messages_retention")
|
||||
return cur.fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list(self, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]:
|
||||
"""One page of retained rows (newest deletions first) + the total,
|
||||
for the pager. Expired rows are purged first (persistently - see
|
||||
count())."""
|
||||
conn = psycopg.connect(self._dsn, autocommit=True)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
self._purge_expired(cur)
|
||||
cur.execute("SELECT count(*) FROM mqtt_messages_retention")
|
||||
total = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, received_at, topic, imei, device, payload,
|
||||
payload_time, deleted_at, deleted_by, expires_at
|
||||
FROM mqtt_messages_retention
|
||||
ORDER BY deleted_at DESC, id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(limit, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return (
|
||||
[
|
||||
{
|
||||
"id": row[0],
|
||||
"received_at": row[1].isoformat(),
|
||||
"topic": row[2],
|
||||
"imei": row[3],
|
||||
"device": row[4],
|
||||
"payload": row[5],
|
||||
"payload_time": row[6].isoformat() if row[6] is not None else None,
|
||||
"deleted_at": row[7].isoformat(),
|
||||
"deleted_by": row[8],
|
||||
"expires_at": row[9].isoformat(),
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
total,
|
||||
)
|
||||
|
||||
|
||||
def _validate_rename_body(
|
||||
data: dict[str, Any], source: str
|
||||
) -> tuple[str, datetime | None, datetime | None, bool]:
|
||||
@@ -848,6 +986,26 @@ def _validate_attribute_body(
|
||||
return key.strip(), data["value"], effective_from, note
|
||||
|
||||
|
||||
def _validate_id_list(data: object, source: str) -> list[int]:
|
||||
"""Validate a {"ids": [...]} request body for delete/restore: a JSON
|
||||
object whose "ids" holds a non-empty list of integers, capped at
|
||||
API_MAX_LIMIT-equivalent 10000. Raises ValueError with a descriptive
|
||||
message otherwise."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("ids"), list):
|
||||
raise ValueError(f'{source}: expected JSON on the form {{"ids": [1, 2, ...]}}')
|
||||
ids = data["ids"]
|
||||
if not ids:
|
||||
raise ValueError(f"{source}: \"ids\" must not be empty")
|
||||
for i, value in enumerate(ids):
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"{source}: ids[{i}] must be an integer")
|
||||
if len(ids) > 10000:
|
||||
raise ValueError(
|
||||
f"{source}: too many ids ({len(ids)}); the limit is 10000"
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
def _validate_import_rows(data: object, source: str) -> list[dict[str, Any]]:
|
||||
"""Validate an import body: a JSON array of exported-message objects
|
||||
(the GET /messages/export shape) — a single object is accepted too.
|
||||
@@ -958,6 +1116,7 @@ def create_app(
|
||||
user_store: UserStore | None = None,
|
||||
session_store: SessionStore | None = None,
|
||||
rename_store: DeviceRenameStore | None = None,
|
||||
retention_policy: MessageRetentionPolicy | None = None,
|
||||
) -> FastAPI:
|
||||
"""Build the API application.
|
||||
|
||||
@@ -974,6 +1133,7 @@ def create_app(
|
||||
|
||||
dsn = _env("DB_DSN")
|
||||
max_limit = int(_env("API_MAX_LIMIT", "10000"))
|
||||
retention_days = int(_env("RETENTION_DAYS", "30"))
|
||||
config = AppConfig(dsn=dsn, max_limit=max_limit)
|
||||
if token_store is None:
|
||||
token_store = TokenStore(
|
||||
@@ -995,6 +1155,8 @@ def create_app(
|
||||
session_store = SessionStore(dsn)
|
||||
if rename_store is None:
|
||||
rename_store = DeviceRenameStore(dsn)
|
||||
if retention_policy is None:
|
||||
retention_policy = MessageRetentionPolicy(dsn, retention_days=retention_days)
|
||||
|
||||
# Brute-force lockout state (in-process, per username).
|
||||
login_attempts: dict[str, dict[str, float]] = {}
|
||||
@@ -1936,6 +2098,90 @@ def create_app(
|
||||
logger.info("imported %d message(s)", imported)
|
||||
return {"status": "imported", "imported": imported}
|
||||
|
||||
# --- soft-delete retention ---------------------------------------------
|
||||
# "Deleting" selected messages MOVES them to mqtt_messages_retention
|
||||
# (same id, original columns untouched) where they stay restorable for
|
||||
# RETENTION_DAYS (env, default 30). Expired rows are purged lazily by
|
||||
# every retention operation below - no scheduler, and an expired row can
|
||||
# never be seen or restored. Delete/restore are superuser+; the reads
|
||||
# (banner count, dialog listing) are open to every authenticated role -
|
||||
# retained messages are the same messages they can already read.
|
||||
|
||||
@app.post(
|
||||
"/messages/delete",
|
||||
tags=["data"],
|
||||
dependencies=[Depends(authenticate), Depends(require_level("superuser"))],
|
||||
)
|
||||
def delete_messages(body: Any = Body(...), request: Request = None) -> dict[str, Any]:
|
||||
"""Move the selected messages to retention. Body: {"ids": [...]} (at
|
||||
least one, capped at 10000; unknown ids are skipped - a selection can
|
||||
go stale under live traffic). The session username is recorded as
|
||||
deleted_by (NULL for app tokens).
|
||||
|
||||
FastAPI injects the real Request for any parameter annotated with
|
||||
the Request type - the `= None` default is for tests only, never
|
||||
used at runtime."""
|
||||
deleted_by = getattr(request.state, "username", None) if request is not None else None
|
||||
try:
|
||||
ids = _validate_id_list(body, "request body")
|
||||
deleted = retention_policy.delete(ids, deleted_by)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except psycopg.Error as exc:
|
||||
raise _db_unavailable(exc) from exc
|
||||
logger.info("moved %d message(s) to retention (by %s)", deleted, deleted_by)
|
||||
return {"status": "deleted", "deleted": deleted}
|
||||
|
||||
@app.get(
|
||||
"/messages/retention/count", tags=["data"], dependencies=[Depends(authenticate)]
|
||||
)
|
||||
def retention_count() -> dict[str, int]:
|
||||
"""Retained rows right now, for the Messages view's banner."""
|
||||
try:
|
||||
return {"count": retention_policy.count()}
|
||||
except psycopg.Error as exc:
|
||||
raise _db_unavailable(exc) from exc
|
||||
|
||||
@app.get("/messages/retention", tags=["data"], dependencies=[Depends(authenticate)])
|
||||
def list_retention(
|
||||
limit: int = Query(100, gt=0, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> dict[str, Any]:
|
||||
"""One page of retained messages (newest deletions first), export
|
||||
shape plus deleted_at/deleted_by/expires_at, with a filter-free
|
||||
total for the pager."""
|
||||
limit = min(limit, config.max_limit)
|
||||
try:
|
||||
items, total = retention_policy.list(limit, offset)
|
||||
except psycopg.Error as exc:
|
||||
raise _db_unavailable(exc) from exc
|
||||
return {"total": total, "limit": limit, "offset": offset, "items": items}
|
||||
|
||||
@app.post(
|
||||
"/messages/retention/restore",
|
||||
tags=["data"],
|
||||
dependencies=[Depends(authenticate), Depends(require_level("superuser"))],
|
||||
)
|
||||
def restore_messages(body: Any = Body(...)) -> dict[str, Any]:
|
||||
"""Move retained messages back into mqtt_messages with their
|
||||
ORIGINAL ids. Body: {"ids": [...]}; unknown ids are skipped (e.g.
|
||||
already purged). Returns {"status": "restored", "restored": n}."""
|
||||
try:
|
||||
ids = _validate_id_list(body, "request body")
|
||||
restored = retention_policy.restore(ids)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except psycopg.Error as exc:
|
||||
raise _db_unavailable(exc) from exc
|
||||
logger.info("restored %d message(s) from retention", restored)
|
||||
return {"status": "restored", "restored": restored}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user