CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 24s
- background.start_periodic(interval, work, label=) replaces the three hand-rolled while-True/sleep/try loops in logging, auth and notifications. - services/scheduler.ScheduledJob replaces the four private BackgroundScheduler copies in recurrence/version_pinning/trash/db_maintenance schedulers; public start_/stop_/reschedule_ surfaces unchanged. - api_keys.hash_token is the one sha256 helper; auth.py used to inline it 5x. - auth.is_registration_open reads via settings.get_admin_setting; notification prefs read via settings.get_setting; _fire_share_email uses _get_user_email. - projects.get_project_summary / milestones.get_project_milestone_summary are now the one-id view of their batch siblings instead of a second copy of the queries; sharing.best_permission_by (was _deduplicate_by_permission) is the one rank-dedup, now also used by list_projects_for_user. - backup: the row builders for every section both exporters carry are named functions, so a column added to one export cannot silently miss the other. - iso() from models.base replaces the attr.isoformat()-if-attr-else-None idiom and db_maintenance._iso across services; backup keeps its explicit shape. - trash.py hoists the sql_delete/timedelta imports it re-imported per function. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""Soft-delete / trash service — single source of truth for recoverable deletes.
|
|
|
|
A delete stamps `deleted_at` + a shared `deleted_batch_id` on the target row and
|
|
its descendants (cascade). Restore clears the batch; purge hard-deletes it; the
|
|
retention cron purges rows older than the configured window. Live reads exclude
|
|
trashed rows via `alive()`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import delete as sql_delete, or_, select, update
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.note import Note
|
|
from scribe.models.project import Project
|
|
from scribe.models.milestone import Milestone
|
|
from scribe.models.rulebook import Rulebook, RulebookTopic, Rule
|
|
from scribe.models.base import iso
|
|
|
|
# entity_type -> Model. Used to resolve which table a trash op targets.
|
|
_MODEL_FOR = {
|
|
"note": Note,
|
|
"task": Note,
|
|
"project": Project,
|
|
"milestone": Milestone,
|
|
"rulebook": Rulebook,
|
|
"topic": RulebookTopic,
|
|
"rule": Rule,
|
|
}
|
|
|
|
|
|
def _owner_clause(model, user_id: int):
|
|
"""Boolean expr scoping `model` rows to the ones `user_id` owns.
|
|
|
|
EVERY trash query (exists-check, restore, purge, list, retention sweep)
|
|
must carry this — a batch_id is a bearer token, so without an owner
|
|
predicate a leaked/guessed id lets one tenant read, restore, or
|
|
permanently destroy another's content. Topics and rules carry no
|
|
user_id of their own; ownership is derived through the parent rulebook
|
|
(or, for project-scoped rules, the owning project).
|
|
"""
|
|
if model is Rulebook:
|
|
return Rulebook.owner_user_id == user_id
|
|
if model is RulebookTopic:
|
|
return RulebookTopic.rulebook_id.in_(
|
|
select(Rulebook.id).where(Rulebook.owner_user_id == user_id)
|
|
)
|
|
if model is Rule:
|
|
return or_(
|
|
Rule.topic_id.in_(
|
|
select(RulebookTopic.id)
|
|
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
|
.where(Rulebook.owner_user_id == user_id)
|
|
),
|
|
Rule.project_id.in_(
|
|
select(Project.id).where(Project.user_id == user_id)
|
|
),
|
|
)
|
|
# Note, Project, Milestone all carry user_id directly.
|
|
return model.user_id == user_id
|
|
|
|
|
|
async def _set(session, model, where, batch, now) -> None:
|
|
"""Stamp deleted_at + batch on live rows matching `where`."""
|
|
await session.execute(
|
|
update(model)
|
|
.where(*where, model.deleted_at.is_(None))
|
|
.values(deleted_at=now, deleted_batch_id=batch)
|
|
)
|
|
|
|
|
|
async def _exists_alive(session, user_id: int, etype: str, eid: int) -> bool:
|
|
model = _MODEL_FOR[etype]
|
|
where = [model.id == eid, model.deleted_at.is_(None), _owner_clause(model, user_id)]
|
|
return (await session.execute(select(model.id).where(*where))).first() is not None
|
|
|
|
|
|
async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now) -> None:
|
|
if etype == "project":
|
|
await _set(session, Note, [Note.user_id == user_id, Note.project_id == eid], batch, now)
|
|
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now)
|
|
# Project-scoped rules cascade with the project they're attached to.
|
|
await _set(session, Rule, [Rule.project_id == eid], batch, now)
|
|
# Suppressions are pure associations (no deleted_at) — hard-delete
|
|
# them here so restoring the project doesn't bring stale mutes back.
|
|
# FK CASCADE would handle a full DELETE on the project row, but the
|
|
# soft-delete path keeps the project row alive; this guarantees the
|
|
# rows are gone whether or not the project ever gets purged.
|
|
from scribe.models.rulebook import (
|
|
project_rule_suppressions, project_topic_suppressions,
|
|
)
|
|
await session.execute(
|
|
sql_delete(project_rule_suppressions)
|
|
.where(project_rule_suppressions.c.project_id == eid)
|
|
)
|
|
await session.execute(
|
|
sql_delete(project_topic_suppressions)
|
|
.where(project_topic_suppressions.c.project_id == eid)
|
|
)
|
|
await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now)
|
|
elif etype == "milestone":
|
|
await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now)
|
|
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.id == eid], batch, now)
|
|
elif etype in ("note", "task"):
|
|
# Stamp the entire sub-task subtree (not just direct children) so a
|
|
# deeply nested task and all its descendants trash/restore as one batch.
|
|
ids = [eid]
|
|
frontier = [eid]
|
|
while frontier:
|
|
children = (await session.execute(
|
|
select(Note.id).where(
|
|
Note.user_id == user_id,
|
|
Note.parent_id.in_(frontier),
|
|
Note.deleted_at.is_(None),
|
|
)
|
|
)).scalars().all()
|
|
frontier = [c for c in children if c not in ids]
|
|
ids.extend(frontier)
|
|
await _set(session, Note, [Note.user_id == user_id, Note.id.in_(ids)], batch, now)
|
|
elif etype == "rulebook":
|
|
topic_ids = (await session.execute(
|
|
select(RulebookTopic.id)
|
|
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
|
.where(Rulebook.id == eid, Rulebook.owner_user_id == user_id)
|
|
)).scalars().all()
|
|
if topic_ids:
|
|
await _set(session, Rule, [Rule.topic_id.in_(topic_ids)], batch, now)
|
|
await _set(session, RulebookTopic, [RulebookTopic.rulebook_id == eid], batch, now)
|
|
await _set(session, Rulebook, [Rulebook.id == eid, Rulebook.owner_user_id == user_id], batch, now)
|
|
elif etype == "topic":
|
|
await _set(session, Rule, [Rule.topic_id == eid], batch, now)
|
|
await _set(session, RulebookTopic, [RulebookTopic.id == eid], batch, now)
|
|
elif etype == "rule":
|
|
await _set(session, Rule, [Rule.id == eid], batch, now)
|
|
else:
|
|
raise ValueError(f"unknown entity_type: {etype!r}")
|
|
|
|
|
|
async def delete(user_id: int, entity_type: str, entity_id: int) -> str | None:
|
|
"""Soft-delete an entity + its descendants under one batch_id.
|
|
|
|
Returns the batch_id, or None if the entity wasn't found / not owned.
|
|
"""
|
|
batch = str(uuid.uuid4())
|
|
now = datetime.now(timezone.utc)
|
|
async with async_session() as session:
|
|
if not await _exists_alive(session, user_id, entity_type, entity_id):
|
|
return 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, Project, Milestone, Rulebook, RulebookTopic, Rule]
|
|
_TYPE = {
|
|
Note: "note", 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, _owner_clause(model, user_id))
|
|
.values(deleted_at=None, deleted_batch_id=None)
|
|
)
|
|
n += res.rowcount or 0
|
|
await session.commit()
|
|
return n
|
|
|
|
|
|
async def restore_entity(user_id: int, entity_type: str, entity_id: int) -> int | None:
|
|
"""Restore ONE trashed entity by id, by reviving the batch that took it.
|
|
|
|
The inverse of `delete(user_id, entity_type, entity_id)`, which returns a
|
|
batch id the caller usually doesn't keep. Callers that need to undo their own
|
|
soft-delete later — snippet un-merge (#2165) — know the entity id, not the
|
|
batch, and looking it up here keeps them from reaching into the column set.
|
|
|
|
Restores the whole batch on purpose, not just the row: the batch is the
|
|
entity plus its cascaded descendants, so reviving the parent alone would
|
|
leave them orphaned in the trash. That is the same thing the trash UI does.
|
|
|
|
Returns rows restored, or None if the entity isn't trashed / not owned.
|
|
"""
|
|
model = next((m for m, label in _TYPE.items() if label == entity_type), None)
|
|
if model is None:
|
|
return None
|
|
async with async_session() as session:
|
|
batch = (await session.execute(
|
|
select(model.deleted_batch_id).where(
|
|
model.id == entity_id,
|
|
_owner_clause(model, user_id),
|
|
model.deleted_at.isnot(None),
|
|
)
|
|
)).scalars().first()
|
|
if not batch:
|
|
return None
|
|
return await restore(user_id, batch)
|
|
|
|
|
|
async def purge(user_id: int, batch_id: str) -> int:
|
|
"""Hard-delete every row in the batch. Irreversible."""
|
|
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, _owner_clause(model, user_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), _owner_clause(model, user_id)
|
|
)
|
|
)).scalars().all()
|
|
for r in rows:
|
|
grp = batches.setdefault(
|
|
r.deleted_batch_id,
|
|
{"batch_id": r.deleted_batch_id,
|
|
"deleted_at": iso(r.deleted_at),
|
|
"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(user_id: int, retention_days: int) -> int:
|
|
"""Cron entry: hard-delete THIS user's rows trashed more than retention_days ago.
|
|
|
|
Scoped to one owner so the scheduler can apply each user's own
|
|
`trash_retention_days` window — a single global sweep would let one
|
|
user's short window prematurely destroy another's data.
|
|
retention_days <= 0 disables auto-purge (returns 0 without touching anything).
|
|
"""
|
|
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,
|
|
_owner_clause(model, user_id),
|
|
)
|
|
)
|
|
n += res.rowcount or 0
|
|
await session.commit()
|
|
return n
|