feat(trash): restore/list_trash/purge/purge_expired + alive() helper
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user