Files
FabledScribe/src/fabledassistant/services/trash.py
T
bvandeusen 2fd9a2300a
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m16s
fix(caldav): point-event round-trip, recurrence push, delete propagation
Drift-audit Group 5 #7/#8 + Group 7 (CalDAV write-path):

- Point events no longer fabricate a 60-min DTEND: caldav.create_event emits
  DTSTART-only when there's no end and no duration, so the next pull doesn't
  read it back as duration_minutes=60 and silently lengthen the event.
- Recurrence edits now propagate: caldav.update_event gains a recurrence param
  (sentinel = leave unchanged; value/empty = set/clear RRULE), and _push_update
  passes the local event's rule so a changed/cleared RRULE isn't overwritten
  by the stale remote rule on the next pull.
- Event deletions propagate to CalDAV: trash.delete captures an event's
  caldav_uid before soft-deleting and fires _push_delete, so a UI/MCP delete
  removes the remote copy instead of leaving it to linger. (delete_event the
  service primitive is kept — still tested/usable — rather than removed.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:20:14 -04:00

277 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, timezone
from sqlalchemy import or_, select, update
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from fabledassistant.models.event import Event
from fabledassistant.models.project import Project
from fabledassistant.models.milestone import Milestone
from fabledassistant.models.rulebook import Rulebook, RulebookTopic, Rule
# entity_type -> Model. Used to resolve which table a trash op targets.
_MODEL_FOR = {
"note": Note,
"task": Note,
"event": Event,
"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, Event, 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 sqlalchemy import delete as _sql_delete
from fabledassistant.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 == "event":
await _set(session, Event, [Event.user_id == user_id, Event.id == eid], 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)
caldav_event: tuple[str, str] | None = None
async with async_session() as session:
if not await _exists_alive(session, user_id, entity_type, entity_id):
return None
# Capture CalDAV linkage before soft-deleting so we can propagate the
# deletion to the external server (the row stays present locally).
if entity_type == "event":
row = (await session.execute(
select(Event.caldav_uid, Event.title).where(
Event.id == entity_id, Event.user_id == user_id
)
)).first()
if row and row[0]:
caldav_event = (row[0], row[1])
await _cascade(session, user_id, entity_type, entity_id, batch, now)
await session.commit()
# Without this the soft-delete only hides the event locally and the remote
# copy lingers forever (and re-appears on any client syncing that server).
if caldav_event:
import asyncio
from fabledassistant.services.events import _push_delete
asyncio.create_task(_push_delete(caldav_event[0], caldav_event[1], user_id))
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, _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 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, _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": 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(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).
"""
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,
_owner_clause(model, user_id),
)
)
n += res.rowcount or 0
await session.commit()
return n