dpdpkit-fastapi 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+
13
+ ${imports if imports else ""}
14
+
15
+ revision = ${repr(up_revision)}
16
+ down_revision = ${repr(down_revision)}
17
+ branch_labels = ${repr(branch_labels)}
18
+ depends_on = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ ${upgrades if upgrades else "pass"}
23
+
24
+
25
+ def downgrade() -> None:
26
+ raise RuntimeError("dpdpkit migrations are forward-only; they never drop consent or audit data")
@@ -0,0 +1,158 @@
1
+ """initial schema
2
+
3
+ Revision ID: 0001
4
+ Revises:
5
+ Create Date: 2026-09-27 20:30:47.602958
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+
13
+ from sqlalchemy.dialects import mysql
14
+ from sqlalchemy.dialects import postgresql
15
+
16
+ revision = "0001"
17
+ down_revision = None
18
+ branch_labels = None
19
+ depends_on = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ op.create_table(
24
+ "dpdp_activity",
25
+ sa.Column("tenant_id", sa.String(length=64), nullable=False),
26
+ sa.Column("principal", sa.String(length=255), nullable=False),
27
+ sa.Column("last_at", sa.DateTime(timezone=True).with_variant(mysql.DATETIME(fsp=6), "mysql"), nullable=False),
28
+ sa.PrimaryKeyConstraint("tenant_id", "principal", name=op.f("pk_dpdp_activity")),
29
+ )
30
+ op.create_table(
31
+ "dpdp_audit",
32
+ sa.Column("id", sa.String(length=64), nullable=False),
33
+ sa.Column("tenant_id", sa.String(length=64), nullable=False),
34
+ sa.Column("seq", sa.BigInteger(), nullable=False),
35
+ sa.Column("actor", sa.String(length=255), nullable=False),
36
+ sa.Column("action", sa.String(length=128), nullable=False),
37
+ sa.Column("subject_type", sa.String(length=64), nullable=False),
38
+ sa.Column("subject_id", sa.String(length=255), nullable=False),
39
+ sa.Column(
40
+ "occurred_at", sa.DateTime(timezone=True).with_variant(mysql.DATETIME(fsp=6), "mysql"), nullable=False
41
+ ),
42
+ sa.Column(
43
+ "data", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False
44
+ ),
45
+ sa.Column("prev_hash", sa.String(length=64), nullable=False),
46
+ sa.Column("hash", sa.String(length=64), nullable=False),
47
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_dpdp_audit")),
48
+ sa.UniqueConstraint("tenant_id", "seq", name="uq_dpdp_audit_tenant_seq"),
49
+ )
50
+ with op.batch_alter_table("dpdp_audit", schema=None) as batch_op:
51
+ batch_op.create_index("ix_dpdp_audit_subject", ["tenant_id", "subject_id"], unique=False)
52
+
53
+ op.create_table(
54
+ "dpdp_consent_events",
55
+ sa.Column("id", sa.String(length=64), nullable=False),
56
+ sa.Column("tenant_id", sa.String(length=64), nullable=False),
57
+ sa.Column("seq", sa.BigInteger(), nullable=False),
58
+ sa.Column("principal", sa.String(length=255), nullable=False),
59
+ sa.Column("purpose", sa.String(length=128), nullable=False),
60
+ sa.Column("action", sa.String(length=16), nullable=False),
61
+ sa.Column("notice_version", sa.Integer(), nullable=True),
62
+ sa.Column("notice_hash", sa.String(length=64), nullable=True),
63
+ sa.Column("locale", sa.String(length=16), nullable=True),
64
+ sa.Column("source", sa.String(length=64), nullable=False),
65
+ sa.Column(
66
+ "occurred_at", sa.DateTime(timezone=True).with_variant(mysql.DATETIME(fsp=6), "mysql"), nullable=False
67
+ ),
68
+ sa.Column(
69
+ "meta", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False
70
+ ),
71
+ sa.Column("prev_hash", sa.String(length=64), nullable=False),
72
+ sa.Column("hash", sa.String(length=64), nullable=False),
73
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_dpdp_consent_events")),
74
+ sa.UniqueConstraint("tenant_id", "seq", name="uq_dpdp_consent_events_tenant_seq"),
75
+ )
76
+ with op.batch_alter_table("dpdp_consent_events", schema=None) as batch_op:
77
+ batch_op.create_index(
78
+ "ix_dpdp_consent_events_lookup", ["tenant_id", "principal", "purpose", "seq"], unique=False
79
+ )
80
+
81
+ op.create_table(
82
+ "dpdp_deliveries",
83
+ sa.Column("id", sa.String(length=64), nullable=False),
84
+ sa.Column("tenant_id", sa.String(length=64), nullable=False),
85
+ sa.Column(
86
+ "created_at", sa.DateTime(timezone=True).with_variant(mysql.DATETIME(fsp=6), "mysql"), nullable=False
87
+ ),
88
+ sa.Column("doc", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False),
89
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_dpdp_deliveries")),
90
+ )
91
+ op.create_table(
92
+ "dpdp_erasure_schedules",
93
+ sa.Column("id", sa.String(length=64), nullable=False),
94
+ sa.Column("tenant_id", sa.String(length=64), nullable=False),
95
+ sa.Column("principal", sa.String(length=255), nullable=False),
96
+ sa.Column("purpose", sa.String(length=128), nullable=False),
97
+ sa.Column("status", sa.String(length=32), nullable=False),
98
+ sa.Column("warn_at", sa.DateTime(timezone=True).with_variant(mysql.DATETIME(fsp=6), "mysql"), nullable=False),
99
+ sa.Column("erase_at", sa.DateTime(timezone=True).with_variant(mysql.DATETIME(fsp=6), "mysql"), nullable=False),
100
+ sa.Column("doc", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False),
101
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_dpdp_erasure_schedules")),
102
+ )
103
+ with op.batch_alter_table("dpdp_erasure_schedules", schema=None) as batch_op:
104
+ batch_op.create_index("ix_dpdp_schedules_due", ["tenant_id", "status", "warn_at"], unique=False)
105
+ batch_op.create_index("ix_dpdp_schedules_principal", ["tenant_id", "principal"], unique=False)
106
+
107
+ op.create_table(
108
+ "dpdp_legal_holds",
109
+ sa.Column("id", sa.String(length=64), nullable=False),
110
+ sa.Column("tenant_id", sa.String(length=64), nullable=False),
111
+ sa.Column("principal", sa.String(length=255), nullable=False),
112
+ sa.Column("active", sa.Boolean(), nullable=False),
113
+ sa.Column("doc", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False),
114
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_dpdp_legal_holds")),
115
+ )
116
+ with op.batch_alter_table("dpdp_legal_holds", schema=None) as batch_op:
117
+ batch_op.create_index("ix_dpdp_holds_principal", ["tenant_id", "principal", "active"], unique=False)
118
+
119
+ op.create_table(
120
+ "dpdp_nominees",
121
+ sa.Column("id", sa.String(length=64), nullable=False),
122
+ sa.Column("tenant_id", sa.String(length=64), nullable=False),
123
+ sa.Column("principal", sa.String(length=255), nullable=False),
124
+ sa.Column("doc", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False),
125
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_dpdp_nominees")),
126
+ )
127
+ with op.batch_alter_table("dpdp_nominees", schema=None) as batch_op:
128
+ batch_op.create_index("ix_dpdp_nominees_principal", ["tenant_id", "principal"], unique=False)
129
+
130
+ op.create_table(
131
+ "dpdp_notices",
132
+ sa.Column("tenant_id", sa.String(length=64), nullable=False),
133
+ sa.Column("version", sa.Integer(), nullable=False),
134
+ sa.Column("locale", sa.String(length=16), nullable=False),
135
+ sa.Column("content_hash", sa.String(length=64), nullable=False),
136
+ sa.Column(
137
+ "published_at", sa.DateTime(timezone=True).with_variant(mysql.DATETIME(fsp=6), "mysql"), nullable=False
138
+ ),
139
+ sa.Column("doc", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False),
140
+ sa.PrimaryKeyConstraint("tenant_id", "version", "locale", name=op.f("pk_dpdp_notices")),
141
+ )
142
+ op.create_table(
143
+ "dpdp_requests",
144
+ sa.Column("id", sa.String(length=64), nullable=False),
145
+ sa.Column("tenant_id", sa.String(length=64), nullable=False),
146
+ sa.Column("principal", sa.String(length=255), nullable=False),
147
+ sa.Column("status", sa.String(length=32), nullable=False),
148
+ sa.Column("opened_at", sa.DateTime(timezone=True).with_variant(mysql.DATETIME(fsp=6), "mysql"), nullable=False),
149
+ sa.Column("doc", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False),
150
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_dpdp_requests")),
151
+ )
152
+ with op.batch_alter_table("dpdp_requests", schema=None) as batch_op:
153
+ batch_op.create_index("ix_dpdp_requests_principal", ["tenant_id", "principal"], unique=False)
154
+ batch_op.create_index("ix_dpdp_requests_status", ["tenant_id", "status"], unique=False)
155
+
156
+
157
+ def downgrade() -> None:
158
+ raise RuntimeError("dpdpkit migrations are forward-only; they never drop consent or audit data")
File without changes
@@ -0,0 +1,424 @@
1
+ """``SqlAlchemyRepository``: dpdpkit-core's Repository on SQLAlchemy 2.0 (Postgres, MySQL, SQLite)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import datetime as dt
7
+ import threading
8
+ from collections.abc import Collection, Iterator
9
+ from typing import Any, TypeVar
10
+
11
+ from pydantic import BaseModel
12
+ from sqlalchemy import Engine, create_engine, event, select
13
+ from sqlalchemy.exc import IntegrityError
14
+ from sqlalchemy.orm import Session, sessionmaker
15
+ from sqlalchemy.pool import StaticPool
16
+
17
+ from dpdpkit._util import as_utc
18
+ from dpdpkit.errors import SequenceConflict
19
+ from dpdpkit.models import (
20
+ ACTIVE_SCHEDULE_STATUSES,
21
+ AuditEntry,
22
+ ConsentAction,
23
+ ConsentEvent,
24
+ DeliveryReceipt,
25
+ ErasureSchedule,
26
+ LegalHold,
27
+ Nominee,
28
+ Notice,
29
+ RequestStatus,
30
+ RightsRequest,
31
+ ScheduleStatus,
32
+ )
33
+
34
+ from .db import (
35
+ ActivityRow,
36
+ AuditRow,
37
+ Base,
38
+ ConsentEventRow,
39
+ DeliveryRow,
40
+ HoldRow,
41
+ NomineeRow,
42
+ NoticeRow,
43
+ RequestRow,
44
+ ScheduleRow,
45
+ )
46
+
47
+ M = TypeVar("M", bound=BaseModel)
48
+
49
+
50
+ def make_engine(url: str, **kwargs: Any) -> Engine:
51
+ """Create an engine with settings dpdpkit relies on (SQLite savepoints, shared in-memory DB)."""
52
+ if url.startswith("sqlite"):
53
+ kwargs.setdefault("connect_args", {"check_same_thread": False})
54
+ if url in ("sqlite://", "sqlite:///:memory:"):
55
+ kwargs.setdefault("poolclass", StaticPool)
56
+ engine = create_engine(url, **kwargs)
57
+ if engine.dialect.name == "sqlite":
58
+ # pysqlite's own transaction handling breaks SAVEPOINT; take control of BEGIN ourselves.
59
+ @event.listens_for(engine, "connect")
60
+ def _connect(dbapi_conn: Any, _record: Any) -> None:
61
+ dbapi_conn.isolation_level = None
62
+
63
+ @event.listens_for(engine, "begin")
64
+ def _begin(conn: Any) -> None:
65
+ conn.exec_driver_sql("BEGIN")
66
+
67
+ return engine
68
+
69
+
70
+ def _doc(model: BaseModel) -> dict[str, Any]:
71
+ return model.model_dump(mode="json")
72
+
73
+
74
+ def _load(cls: type[M], doc: dict[str, Any]) -> M:
75
+ return cls.model_validate(doc)
76
+
77
+
78
+ class SqlAlchemyRepository:
79
+ def __init__(self, engine: Engine | str) -> None:
80
+ self.engine = make_engine(engine) if isinstance(engine, str) else engine
81
+ self.Session = sessionmaker(self.engine, expire_on_commit=False)
82
+ self._local = threading.local()
83
+
84
+ def create_all(self) -> None:
85
+ """Create tables directly (tests and demos). Production installs use ``dpdpkit migrate``."""
86
+ Base.metadata.create_all(self.engine)
87
+
88
+ # ------------------------------------------------------------------ sessions
89
+
90
+ @contextlib.contextmanager
91
+ def transaction(self) -> Iterator[None]:
92
+ if getattr(self._local, "session", None) is not None:
93
+ yield
94
+ return
95
+ session = self.Session()
96
+ self._local.session = session
97
+ try:
98
+ yield
99
+ session.commit()
100
+ except BaseException:
101
+ session.rollback()
102
+ raise
103
+ finally:
104
+ session.close()
105
+ self._local.session = None
106
+
107
+ @contextlib.contextmanager
108
+ def _session(self) -> Iterator[Session]:
109
+ current: Session | None = getattr(self._local, "session", None)
110
+ if current is not None:
111
+ yield current
112
+ return
113
+ with self.Session() as session:
114
+ try:
115
+ yield session
116
+ session.commit()
117
+ except BaseException:
118
+ session.rollback()
119
+ raise
120
+
121
+ def _insert(self, row: Any, what: str) -> None:
122
+ with self._session() as s:
123
+ try:
124
+ with s.begin_nested():
125
+ s.add(row)
126
+ except IntegrityError as exc:
127
+ raise SequenceConflict(f"{what} sequence conflict") from exc
128
+
129
+ # ------------------------------------------------------------------ consent ledger
130
+
131
+ @staticmethod
132
+ def _event(r: ConsentEventRow) -> ConsentEvent:
133
+ return ConsentEvent(
134
+ tenant_id=r.tenant_id,
135
+ id=r.id,
136
+ seq=r.seq,
137
+ principal=r.principal,
138
+ purpose=r.purpose,
139
+ action=ConsentAction(r.action),
140
+ notice_version=r.notice_version,
141
+ notice_hash=r.notice_hash,
142
+ locale=r.locale,
143
+ source=r.source,
144
+ occurred_at=as_utc(r.occurred_at),
145
+ metadata=r.meta or {},
146
+ prev_hash=r.prev_hash,
147
+ hash=r.hash,
148
+ )
149
+
150
+ def append_consent_event(self, e: ConsentEvent) -> None:
151
+ self._insert(
152
+ ConsentEventRow(
153
+ id=e.id,
154
+ tenant_id=e.tenant_id,
155
+ seq=e.seq,
156
+ principal=e.principal,
157
+ purpose=e.purpose,
158
+ action=e.action.value,
159
+ notice_version=e.notice_version,
160
+ notice_hash=e.notice_hash,
161
+ locale=e.locale,
162
+ source=e.source,
163
+ occurred_at=as_utc(e.occurred_at),
164
+ meta=e.metadata,
165
+ prev_hash=e.prev_hash,
166
+ hash=e.hash,
167
+ ),
168
+ "consent ledger",
169
+ )
170
+
171
+ def last_consent_event(self, tenant_id: str) -> ConsentEvent | None:
172
+ with self._session() as s:
173
+ row = s.scalars(
174
+ select(ConsentEventRow)
175
+ .where(ConsentEventRow.tenant_id == tenant_id)
176
+ .order_by(ConsentEventRow.seq.desc())
177
+ .limit(1)
178
+ ).first()
179
+ return self._event(row) if row else None
180
+
181
+ def latest_consent_event(self, tenant_id: str, principal: str, purpose: str) -> ConsentEvent | None:
182
+ with self._session() as s:
183
+ row = s.scalars(
184
+ select(ConsentEventRow)
185
+ .where(
186
+ ConsentEventRow.tenant_id == tenant_id,
187
+ ConsentEventRow.principal == principal,
188
+ ConsentEventRow.purpose == purpose,
189
+ )
190
+ .order_by(ConsentEventRow.seq.desc())
191
+ .limit(1)
192
+ ).first()
193
+ return self._event(row) if row else None
194
+
195
+ def list_consent_events(
196
+ self, tenant_id: str, *, principal: str | None = None, after_seq: int = 0, limit: int | None = None
197
+ ) -> list[ConsentEvent]:
198
+ q = select(ConsentEventRow).where(ConsentEventRow.tenant_id == tenant_id, ConsentEventRow.seq > after_seq)
199
+ if principal is not None:
200
+ q = q.where(ConsentEventRow.principal == principal)
201
+ q = q.order_by(ConsentEventRow.seq)
202
+ if limit is not None:
203
+ q = q.limit(limit)
204
+ with self._session() as s:
205
+ return [self._event(r) for r in s.scalars(q)]
206
+
207
+ # ------------------------------------------------------------------ audit
208
+
209
+ @staticmethod
210
+ def _audit(r: AuditRow) -> AuditEntry:
211
+ return AuditEntry(
212
+ tenant_id=r.tenant_id,
213
+ id=r.id,
214
+ seq=r.seq,
215
+ actor=r.actor,
216
+ action=r.action,
217
+ subject_type=r.subject_type,
218
+ subject_id=r.subject_id,
219
+ occurred_at=as_utc(r.occurred_at),
220
+ data=r.data or {},
221
+ prev_hash=r.prev_hash,
222
+ hash=r.hash,
223
+ )
224
+
225
+ def append_audit(self, a: AuditEntry) -> None:
226
+ self._insert(
227
+ AuditRow(
228
+ id=a.id,
229
+ tenant_id=a.tenant_id,
230
+ seq=a.seq,
231
+ actor=a.actor,
232
+ action=a.action,
233
+ subject_type=a.subject_type,
234
+ subject_id=a.subject_id,
235
+ occurred_at=as_utc(a.occurred_at),
236
+ data=a.data,
237
+ prev_hash=a.prev_hash,
238
+ hash=a.hash,
239
+ ),
240
+ "audit",
241
+ )
242
+
243
+ def last_audit(self, tenant_id: str) -> AuditEntry | None:
244
+ with self._session() as s:
245
+ row = s.scalars(
246
+ select(AuditRow).where(AuditRow.tenant_id == tenant_id).order_by(AuditRow.seq.desc()).limit(1)
247
+ ).first()
248
+ return self._audit(row) if row else None
249
+
250
+ def list_audit(
251
+ self, tenant_id: str, *, subject_id: str | None = None, after_seq: int = 0, limit: int | None = None
252
+ ) -> list[AuditEntry]:
253
+ q = select(AuditRow).where(AuditRow.tenant_id == tenant_id, AuditRow.seq > after_seq)
254
+ if subject_id is not None:
255
+ q = q.where(AuditRow.subject_id == subject_id)
256
+ q = q.order_by(AuditRow.seq)
257
+ if limit is not None:
258
+ q = q.limit(limit)
259
+ with self._session() as s:
260
+ return [self._audit(r) for r in s.scalars(q)]
261
+
262
+ # ------------------------------------------------------------------ notices
263
+
264
+ def save_notice(self, notice: Notice) -> None:
265
+ with self._session() as s:
266
+ s.merge(
267
+ NoticeRow(
268
+ tenant_id=notice.tenant_id,
269
+ version=notice.version,
270
+ locale=notice.locale,
271
+ content_hash=notice.content_hash,
272
+ published_at=as_utc(notice.published_at),
273
+ doc=_doc(notice),
274
+ )
275
+ )
276
+
277
+ def list_notices(self, tenant_id: str, *, version: int | None = None) -> list[Notice]:
278
+ q = select(NoticeRow).where(NoticeRow.tenant_id == tenant_id)
279
+ if version is not None:
280
+ q = q.where(NoticeRow.version == version)
281
+ with self._session() as s:
282
+ return [_load(Notice, r.doc) for r in s.scalars(q.order_by(NoticeRow.version, NoticeRow.locale))]
283
+
284
+ # ------------------------------------------------------------------ rights
285
+
286
+ def save_request(self, req: RightsRequest) -> None:
287
+ with self._session() as s:
288
+ s.merge(
289
+ RequestRow(
290
+ id=req.id,
291
+ tenant_id=req.tenant_id,
292
+ principal=req.principal,
293
+ status=req.status.value,
294
+ opened_at=as_utc(req.opened_at),
295
+ doc=_doc(req),
296
+ )
297
+ )
298
+
299
+ def get_request(self, tenant_id: str, request_id: str) -> RightsRequest | None:
300
+ with self._session() as s:
301
+ row = s.get(RequestRow, request_id)
302
+ return _load(RightsRequest, row.doc) if row and row.tenant_id == tenant_id else None
303
+
304
+ def list_requests(
305
+ self,
306
+ tenant_id: str,
307
+ *,
308
+ principal: str | None = None,
309
+ statuses: Collection[RequestStatus] | None = None,
310
+ ) -> list[RightsRequest]:
311
+ q = select(RequestRow).where(RequestRow.tenant_id == tenant_id)
312
+ if principal is not None:
313
+ q = q.where(RequestRow.principal == principal)
314
+ if statuses is not None:
315
+ q = q.where(RequestRow.status.in_([st.value for st in statuses]))
316
+ with self._session() as s:
317
+ return [_load(RightsRequest, r.doc) for r in s.scalars(q.order_by(RequestRow.opened_at))]
318
+
319
+ def save_nominee(self, nominee: Nominee) -> None:
320
+ with self._session() as s:
321
+ s.merge(
322
+ NomineeRow(id=nominee.id, tenant_id=nominee.tenant_id, principal=nominee.principal, doc=_doc(nominee))
323
+ )
324
+
325
+ def list_nominees(self, tenant_id: str, principal: str) -> list[Nominee]:
326
+ q = select(NomineeRow).where(NomineeRow.tenant_id == tenant_id, NomineeRow.principal == principal)
327
+ with self._session() as s:
328
+ return [_load(Nominee, r.doc) for r in s.scalars(q)]
329
+
330
+ # ------------------------------------------------------------------ retention
331
+
332
+ def save_schedule(self, sch: ErasureSchedule) -> None:
333
+ with self._session() as s:
334
+ s.merge(
335
+ ScheduleRow(
336
+ id=sch.id,
337
+ tenant_id=sch.tenant_id,
338
+ principal=sch.principal,
339
+ purpose=sch.purpose,
340
+ status=sch.status.value,
341
+ warn_at=as_utc(sch.warn_at),
342
+ erase_at=as_utc(sch.erase_at),
343
+ doc=_doc(sch),
344
+ )
345
+ )
346
+
347
+ def get_schedule(self, tenant_id: str, schedule_id: str) -> ErasureSchedule | None:
348
+ with self._session() as s:
349
+ row = s.get(ScheduleRow, schedule_id)
350
+ return _load(ErasureSchedule, row.doc) if row and row.tenant_id == tenant_id else None
351
+
352
+ def list_schedules(
353
+ self,
354
+ tenant_id: str,
355
+ *,
356
+ principal: str | None = None,
357
+ statuses: Collection[ScheduleStatus] | None = None,
358
+ ) -> list[ErasureSchedule]:
359
+ q = select(ScheduleRow).where(ScheduleRow.tenant_id == tenant_id)
360
+ if principal is not None:
361
+ q = q.where(ScheduleRow.principal == principal)
362
+ if statuses is not None:
363
+ q = q.where(ScheduleRow.status.in_([st.value for st in statuses]))
364
+ with self._session() as s:
365
+ return [_load(ErasureSchedule, r.doc) for r in s.scalars(q.order_by(ScheduleRow.erase_at, ScheduleRow.id))]
366
+
367
+ def due_erasures(self, tenant_id: str, now: dt.datetime) -> list[ErasureSchedule]:
368
+ now = as_utc(now)
369
+ q = select(ScheduleRow).where(
370
+ ScheduleRow.tenant_id == tenant_id,
371
+ ScheduleRow.status.in_([st.value for st in ACTIVE_SCHEDULE_STATUSES]),
372
+ (ScheduleRow.warn_at <= now) | (ScheduleRow.erase_at <= now),
373
+ )
374
+ with self._session() as s:
375
+ return [_load(ErasureSchedule, r.doc) for r in s.scalars(q.order_by(ScheduleRow.erase_at))]
376
+
377
+ def save_hold(self, hold: LegalHold) -> None:
378
+ with self._session() as s:
379
+ s.merge(
380
+ HoldRow(
381
+ id=hold.id,
382
+ tenant_id=hold.tenant_id,
383
+ principal=hold.principal,
384
+ active=hold.released_at is None,
385
+ doc=_doc(hold),
386
+ )
387
+ )
388
+
389
+ def list_holds(self, tenant_id: str, *, principal: str | None = None, active_only: bool = True) -> list[LegalHold]:
390
+ q = select(HoldRow).where(HoldRow.tenant_id == tenant_id)
391
+ if principal is not None:
392
+ q = q.where(HoldRow.principal == principal)
393
+ if active_only:
394
+ q = q.where(HoldRow.active.is_(True))
395
+ with self._session() as s:
396
+ return [_load(LegalHold, r.doc) for r in s.scalars(q)]
397
+
398
+ def record_activity(self, tenant_id: str, principal: str, at: dt.datetime) -> None:
399
+ with self._session() as s:
400
+ row = s.get(ActivityRow, (tenant_id, principal))
401
+ if row is None:
402
+ s.add(ActivityRow(tenant_id=tenant_id, principal=principal, last_at=as_utc(at)))
403
+ elif as_utc(row.last_at) < as_utc(at):
404
+ row.last_at = as_utc(at)
405
+
406
+ def last_activity(self, tenant_id: str, principal: str) -> dt.datetime | None:
407
+ with self._session() as s:
408
+ row = s.get(ActivityRow, (tenant_id, principal))
409
+ return as_utc(row.last_at) if row else None
410
+
411
+ # ------------------------------------------------------------------ notifications
412
+
413
+ def save_delivery(self, receipt: DeliveryReceipt) -> None:
414
+ with self._session() as s:
415
+ s.merge(
416
+ DeliveryRow(
417
+ id=receipt.id, tenant_id=receipt.tenant_id, created_at=as_utc(receipt.created_at), doc=_doc(receipt)
418
+ )
419
+ )
420
+
421
+ def get_delivery(self, tenant_id: str, receipt_id: str) -> DeliveryReceipt | None:
422
+ with self._session() as s:
423
+ row = s.get(DeliveryRow, receipt_id)
424
+ return _load(DeliveryReceipt, row.doc) if row and row.tenant_id == tenant_id else None