Files
FabledScribe/alembic/versions/0061_partial_unique_topic_rule_titles.py
bvandeusen 7ce5bb8450
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Has been cancelled
fix(lifecycle): OAuth pw 500, invite lockout, reminder re-arm, partial-unique
Drift-audit Group 8 (lifecycle gaps):

- change_password no longer 500s for OAuth-only users: short-circuit when
  password_hash is None (verify_password would crash on None) so the route
  returns a clean 4xx instead of a 500.
- register_with_invitation no longer locks the invitee out on a username
  collision: create the user FIRST, then mark the token used, so a failed
  creation (409) leaves the single-use invite valid for retry.
- update_event re-arms reminder_sent_at when start_dt/reminder_minutes change,
  so a rescheduled event fires again instead of being permanently suppressed.
- Migration 0061: uq_topic_per_rulebook / uq_rule_per_topic become PARTIAL
  unique indexes (WHERE deleted_at IS NULL). Trashing 'X' then recreating it
  no longer 500s on the dead row's title. Model __table_args__ updated to match.

Deferred: per-occurrence reminders for recurring events (event_scheduler) —
needs a per-occurrence reminder-state design, not a one-line gate tweak.

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

50 lines
1.5 KiB
Python

"""partial-unique topic/rule titles (ignore soft-deleted rows)
Revision ID: 0061
Revises: 0060
Create Date: 2026-06-02
Topics and rules are soft-deleted (SoftDeleteMixin), but uq_topic_per_rulebook
and uq_rule_per_topic were plain UNIQUE constraints. Trashing a topic/rule
named "X" then creating a new "X" — or restoring into a reused title slot —
collided with the dead row and raised an unhandled 500. Replace the full
UNIQUE constraints with partial unique indexes that only consider live
(deleted_at IS NULL) rows.
"""
from alembic import op
import sqlalchemy as sa
revision = "0061"
down_revision = "0060"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_constraint("uq_topic_per_rulebook", "rulebook_topics", type_="unique")
op.create_index(
"uq_topic_per_rulebook",
"rulebook_topics",
["rulebook_id", "title"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
op.drop_constraint("uq_rule_per_topic", "rules", type_="unique")
op.create_index(
"uq_rule_per_topic",
"rules",
["topic_id", "title"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("uq_rule_per_topic", table_name="rules")
op.create_unique_constraint("uq_rule_per_topic", "rules", ["topic_id", "title"])
op.drop_index("uq_topic_per_rulebook", table_name="rulebook_topics")
op.create_unique_constraint(
"uq_topic_per_rulebook", "rulebook_topics", ["rulebook_id", "title"]
)