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.
- dpdpkit/fastapi/__init__.py +22 -0
- dpdpkit/fastapi/admin_static/index.html +23 -0
- dpdpkit/fastapi/app.py +286 -0
- dpdpkit/fastapi/cli.py +39 -0
- dpdpkit/fastapi/db.py +152 -0
- dpdpkit/fastapi/http.py +84 -0
- dpdpkit/fastapi/migrations/__init__.py +34 -0
- dpdpkit/fastapi/migrations/env.py +44 -0
- dpdpkit/fastapi/migrations/script.py.mako +26 -0
- dpdpkit/fastapi/migrations/versions/0001_initial_schema.py +158 -0
- dpdpkit/fastapi/py.typed +0 -0
- dpdpkit/fastapi/repository.py +424 -0
- dpdpkit/fastapi/router.py +250 -0
- dpdpkit_fastapi-0.1.0a1.dist-info/METADATA +122 -0
- dpdpkit_fastapi-0.1.0a1.dist-info/RECORD +18 -0
- dpdpkit_fastapi-0.1.0a1.dist-info/WHEEL +4 -0
- dpdpkit_fastapi-0.1.0a1.dist-info/entry_points.txt +2 -0
- dpdpkit_fastapi-0.1.0a1.dist-info/licenses/LICENSE +202 -0
|
@@ -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")
|
dpdpkit/fastapi/py.typed
ADDED
|
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
|