feat(trash): restore/list_trash/purge/purge_expired + alive() helper

This commit is contained in:
2026-05-28 20:02:21 -04:00
parent ce47ebc7de
commit f80c327ecf
2 changed files with 164 additions and 0 deletions
+93
View File
@@ -94,3 +94,96 @@ async def delete(user_id: int, entity_type: str, entity_id: int) -> str | None:
await _cascade(session, user_id, entity_type, entity_id, batch, now)
await session.commit()
return batch
# All soft-deletable models, and their trash-listing type label.
_ALL = [Note, Event, Project, Milestone, Rulebook, RulebookTopic, Rule]
_TYPE = {
Note: "note", Event: "event", Project: "project", Milestone: "milestone",
Rulebook: "rulebook", RulebookTopic: "topic", Rule: "rule",
}
def alive(stmt, model):
"""Append a 'not trashed' filter to a select. Use in every live read."""
return stmt.where(model.deleted_at.is_(None))
async def restore(user_id: int, batch_id: str) -> int:
"""Clear deleted_at for every row in the batch. Returns rows restored."""
n = 0
async with async_session() as session:
for model in _ALL:
res = await session.execute(
update(model)
.where(model.deleted_batch_id == batch_id)
.values(deleted_at=None, deleted_batch_id=None)
)
n += res.rowcount or 0
await session.commit()
return n
async def purge(user_id: int, batch_id: str) -> int:
"""Hard-delete every row in the batch. Irreversible."""
from sqlalchemy import delete as sql_delete
n = 0
async with async_session() as session:
for model in _ALL:
res = await session.execute(
sql_delete(model).where(model.deleted_batch_id == batch_id)
)
n += res.rowcount or 0
await session.commit()
return n
async def list_trash(user_id: int) -> list[dict]:
"""Trashed rows across all soft-deletable tables, grouped by batch_id."""
batches: dict[str, dict] = {}
async with async_session() as session:
for model in _ALL:
rows = (await session.execute(
select(model).where(model.deleted_at.isnot(None))
)).scalars().all()
for r in rows:
grp = batches.setdefault(
r.deleted_batch_id,
{"batch_id": r.deleted_batch_id,
"deleted_at": r.deleted_at.isoformat() if r.deleted_at else None,
"items": []},
)
grp["items"].append({
"type": _TYPE[model], "id": r.id,
"title": getattr(r, "title", "") or "",
})
out = list(batches.values())
for g in out:
lead = g["items"][0]
g["summary"] = lead["title"] or f"{lead['type']} {lead['id']}"
g["count"] = len(g["items"])
out.sort(key=lambda g: g["deleted_at"] or "", reverse=True)
return out
async def purge_expired(retention_days: int) -> int:
"""Cron entry: hard-delete rows trashed more than retention_days ago.
retention_days <= 0 disables auto-purge (returns 0 without touching anything).
"""
from datetime import timedelta
from sqlalchemy import delete as sql_delete
if retention_days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
n = 0
async with async_session() as session:
for model in _ALL:
res = await session.execute(
sql_delete(model).where(
model.deleted_at.isnot(None), model.deleted_at < cutoff
)
)
n += res.rowcount or 0
await session.commit()
return n