|
| 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