- A26 — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…` ne produit plus de chemin UNC sous Windows ; `.env.example` ne promet plus PostgreSQL ; raise au boot si `APP_SECRET_KEY` vaut la valeur par défaut - A33 — rate limit : préfixes manquants (`/scim/v2/`, `/workspace/`, `/db/` + non-GET sur `/s/` et `/f/`), limite lue dans `settings.rate_limit_requests` (60 annoncés / 100 codés en dur), clé `X-Forwarded-For` seulement derrière un proxy local (anti-spoof), `_store` épuré (mémoire bornée) + test dédié - A34 — `_spawn()` : les 10 schedulers loggent leur exception et redémarrent après 10 s au lieu de mourir en silence ; 2 `logger.debug` de scheduler → warning - A35 — OpenAPI régénéré 439 → 511 chemins (`info.version 7.3.9`), README (était v6.7.0), compteur API_GUIDE, titre dupliqué retiré du ROADMAP ; le drift Python 3.12/3.13 reste noté (rebuild d'image à valider) - A36 — 4 dépendances mortes purgées de requirements.txt (aiosqlite, slowapi, loguru, packaging = 0 import) ; pyproject reste sans [project] : Docker et la CI installent requirements.txt, dupliquer les 22 deps créerait 2 sources - A43 — 15 `datetime.utcnow()` → `now(UTC).replace(tzinfo=None)` (format ISO naïf identique, warnings de dépréciation divisés : 2374 → 926) suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
88 lines
3.1 KiB
Python
88 lines
3.1 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 UTC, 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.now(UTC).replace(tzinfo=None) - 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)
|