FlowDeck CI / lint (push) Successful in 43s
FlowDeck CI / test (push) Successful in 4m2s
FlowDeck CI / lint (pull_request) Successful in 42s
FlowDeck CI / test (pull_request) Successful in 4m3s
FlowDeck CI / docker (push) Successful in 1m2s
FlowDeck CI / docker (pull_request) Successful in 35s
tests/conftest.py: mutate the settings singleton (instead of rebinding) so DB + backup dir are isolated per test -> pytest-xdist safe. Real backup tests (snapshot/prune/admin API) and OAuth mock tests (Gitea/GitHub/link) replace the previous skips. init_db() now also creates webhook_subscriptions (full schema without the FastAPI lifespan). ruff check is clean; .eslintrc.json migrated to eslint.config.mjs (flat config). CI: lint job (ruff + eslint), parallel tests (-n auto), run on every branch push. VERSION 5.11.1.
88 lines
3.0 KiB
Python
88 lines
3.0 KiB
Python
"""FlowDeck — Global trash (v5.4.0): automatic 30-day purge.
|
|
|
|
Pages soft-deleted via ``deleted_at`` stay in the trash for the retention
|
|
window (default 30 days, matching the UI copy in ``trash.html``), then are
|
|
permanently deleted together with their child pages and orphaned versions.
|
|
The purge is run from a background scheduler in ``app.main`` so it is fully
|
|
automatic (no per-request cost).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
|
|
from app.db import get_conn
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
|
|
|
|
|
|
def _parse_dt(value: str) -> datetime | None:
|
|
"""Parse the ISO-ish ``deleted_at`` timestamps (either 'YYYY-MM-DD...' or
|
|
SQLite 'YYYY-MM-DD HH:MM:SS'). Returns None when unparseable."""
|
|
if not value:
|
|
return None
|
|
try:
|
|
s = value[:19].replace("T", " ")
|
|
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
|
|
except ValueError:
|
|
try:
|
|
dt = datetime.fromisoformat(value)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
if not ISO_RE.match(value):
|
|
return None
|
|
return dt
|
|
|
|
|
|
def purge_expired(days: int = 30) -> dict:
|
|
"""Permanently delete pages whose ``deleted_at`` is older than ``days``.
|
|
|
|
Children are re-parented to their grandparent before deletion in order
|
|
to avoid silently dropping whole sub-trees; deleted pages are removed
|
|
together with their ``page_versions`` (FK cascade).
|
|
|
|
Returns a summary of what was purged.
|
|
"""
|
|
cutoff = datetime.utcnow() - timedelta(days=days)
|
|
purged: list[int] = []
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, deleted_at FROM pages WHERE deleted_at IS NOT NULL"
|
|
).fetchall()
|
|
for row in rows:
|
|
dt = _parse_dt(row["deleted_at"])
|
|
if dt is None or dt >= cutoff:
|
|
continue
|
|
page_id = row["id"]
|
|
# Promote direct children to the deleted page's parent so no live
|
|
# sub-tree is orphaned (matches the permanent-delete semantics).
|
|
conn.execute(
|
|
"UPDATE pages SET parent_id=(SELECT parent_id FROM pages WHERE id=?) "
|
|
"WHERE parent_id=?",
|
|
(page_id, page_id),
|
|
)
|
|
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
|
purged.append(page_id)
|
|
conn.commit()
|
|
if purged:
|
|
logger.info("Trash purge: %d expired page(s) permanently deleted", len(purged))
|
|
return {"purged": purged, "count": len(purged)}
|
|
|
|
|
|
async def trash_purge_scheduler(interval_hours: int = 24):
|
|
"""Background loop purging the trash once a day (idempotent, cheap when
|
|
there is nothing expired)."""
|
|
import asyncio
|
|
|
|
# Run once shortly after startup, then on the configured interval.
|
|
await asyncio.sleep(5)
|
|
while True:
|
|
try:
|
|
purge_expired(days=30)
|
|
except Exception as exc: # pragma: no cover - defensive only
|
|
logger.warning("Trash purge failed: %s", exc)
|
|
await asyncio.sleep(interval_hours * 3600)
|