Skip to content

Commit 7628bf2

Browse files
committed
feat(grz-db): add cases table and pluggable case resolver
1 parent d088266 commit 7628bf2

7 files changed

Lines changed: 1067 additions & 2 deletions

File tree

packages/grz-db/src/grz_db/errors.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,35 @@ def __init__(self, submission_id: str):
5454

5555
class DatabaseConfigurationError(DatabaseError):
5656
"""Exception for database configuration issues."""
57+
58+
59+
class CaseError(GrzDbError):
60+
"""Base class for errors related to cases."""
61+
62+
63+
class CaseNotFoundError(CaseError):
64+
"""Exception for when a case is not found in the database."""
65+
66+
def __init__(self, case_id: int):
67+
super().__init__(f"Case not found for ID {case_id}")
68+
69+
70+
class DuplicatePsnError(CaseError):
71+
"""Exception for when a case with the given RKI pseudonym already exists."""
72+
73+
def __init__(self, psn: str):
74+
super().__init__(f"A case with pseudonym '{psn}' already exists")
75+
76+
77+
class CaseHasLinkedSubmissionsError(CaseError):
78+
"""Exception for when a case cannot be deleted because submissions are still linked to it."""
79+
80+
def __init__(self, case_id: int, count: int):
81+
super().__init__(f"Case {case_id} still has {count} linked submission(s) and cannot be deleted")
82+
83+
84+
class SubmissionTypeInvalidForCaseError(SubmissionError):
85+
"""Exception for when a submission's type is incompatible with its case state."""
86+
87+
def __init__(self, message: str):
88+
super().__init__(message)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""add cases table and link submissions
2+
3+
Revision ID: f8c1a4b7e2d9
4+
Revises: 66f36abbea34
5+
Create Date: 2026-07-08 00:00:00.000000+00:00
6+
7+
Introduces the ``cases`` table and links each submission to its case via ``submissions.case_id``.
8+
9+
A case's authoritative identity is ``psn`` (the RKI pseudonym), unique once assigned.
10+
``submitter_id`` and ``local_case_id`` are resolution keys (indexed, but not a uniqueness
11+
constraint) used to locate a case before a ``psn`` exists. Existing submissions are grouped by
12+
``(submitter_id, pseudonym)`` and backfilled into cases. ``submissions.submitter_id`` is kept.
13+
"""
14+
15+
from collections.abc import Sequence
16+
17+
import sqlalchemy as sa
18+
from alembic import op
19+
from sqlmodel.sql.sqltypes import AutoString
20+
21+
# revision identifiers, used by Alembic.
22+
revision: str = "f8c1a4b7e2d9"
23+
down_revision: str | Sequence[str] | None = "66f36abbea34"
24+
branch_labels: str | Sequence[str] | None = None
25+
depends_on: str | Sequence[str] | None = None
26+
27+
28+
def upgrade() -> None:
29+
"""Upgrade schema."""
30+
bind = op.get_bind()
31+
32+
# Fail fast only on genuinely inconsistent data: more than one 'initial' sharing a
33+
# (submitter_id, local_case_id). Rows that cannot form a key are simply left unlinked.
34+
duplicate_initials = bind.execute(
35+
sa.text(
36+
"SELECT submitter_id, pseudonym, COUNT(*) AS n FROM submissions "
37+
"WHERE pseudonym IS NOT NULL AND submitter_id IS NOT NULL AND submission_type = 'initial' "
38+
"GROUP BY submitter_id, pseudonym HAVING COUNT(*) > 1"
39+
)
40+
).fetchall()
41+
if duplicate_initials:
42+
groups = ", ".join(f"({row[0]}, {row[1]}): {row[2]}" for row in duplicate_initials)
43+
raise RuntimeError(
44+
"Cannot backfill cases: more than one 'initial' submission shares the same "
45+
f"(submitter_id, local_case_id): {groups}"
46+
)
47+
48+
# --- Create the cases table ---
49+
op.create_table(
50+
"cases",
51+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
52+
sa.Column("psn", AutoString(), nullable=True),
53+
sa.Column("submitter_id", AutoString(), nullable=True),
54+
sa.Column("local_case_id", AutoString(), nullable=True),
55+
)
56+
# psn is the authoritative identity: unique when present.
57+
op.create_index(
58+
"ux_cases_psn",
59+
"cases",
60+
["psn"],
61+
unique=True,
62+
postgresql_where=sa.text("psn IS NOT NULL"),
63+
sqlite_where=sa.text("psn IS NOT NULL"),
64+
)
65+
# (submitter_id, local_case_id) is a lookup key, not a uniqueness constraint.
66+
op.create_index("ix_cases_submitter_local_case", "cases", ["submitter_id", "local_case_id"])
67+
68+
# --- Link column on submissions ---
69+
op.add_column("submissions", sa.Column("case_id", sa.Integer(), nullable=True))
70+
op.create_index("ix_submissions_case_id", "submissions", ["case_id"])
71+
if bind.dialect.name == "postgresql":
72+
# SQLite cannot ALTER TABLE ADD CONSTRAINT; the ORM declares the FK regardless.
73+
op.create_foreign_key(
74+
"fk_submissions_case_id_cases",
75+
"submissions",
76+
"cases",
77+
["case_id"],
78+
["id"],
79+
)
80+
81+
# --- Backfill: one case per distinct (submitter_id, pseudonym), storing the keys, then link ---
82+
op.execute(
83+
"INSERT INTO cases (submitter_id, local_case_id) "
84+
"SELECT DISTINCT submitter_id, pseudonym FROM submissions "
85+
"WHERE pseudonym IS NOT NULL AND submitter_id IS NOT NULL"
86+
)
87+
op.execute(
88+
"UPDATE submissions SET case_id = ("
89+
"SELECT c.id FROM cases c "
90+
"WHERE c.submitter_id = submissions.submitter_id AND c.local_case_id = submissions.pseudonym"
91+
") WHERE pseudonym IS NOT NULL AND submitter_id IS NOT NULL"
92+
)
93+
94+
# --- Enforce at most one initial submission per case ---
95+
op.create_index(
96+
"ux_submissions_one_initial_per_case",
97+
"submissions",
98+
["case_id"],
99+
unique=True,
100+
postgresql_where=sa.text("submission_type = 'initial'"),
101+
sqlite_where=sa.text("submission_type = 'initial'"),
102+
)
103+
104+
105+
def downgrade() -> None:
106+
"""Downgrade schema."""
107+
raise RuntimeError("Downgrades not supported.")

0 commit comments

Comments
 (0)