feat: roll-call votes + granular alert filter fix (v0.9.5)

Roll-call votes:
- Migration 0017: bill_votes + member_vote_positions tables
- Fetch vote XML directly from House Clerk / Senate LIS URLs
  embedded in bill actions recordedVotes objects
- GET /api/bills/{id}/votes triggers background fetch on first view
- VotePanel on bill detail: yea/nay bar, result badge, followed
  member positions with Sen./Rep. title, party badge, and state

Alert filter fix:
- _should_dispatch returns True when alert_filters is None so users
  who haven't saved filters still receive all notifications

Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-02 20:33:32 -05:00
parent 676bf1b78d
commit 91473e6464
13 changed files with 673 additions and 3 deletions

View File

@@ -0,0 +1,63 @@
"""Add bill_votes and member_vote_positions tables
Revision ID: 0017
Revises: 0016
"""
from alembic import op
import sqlalchemy as sa
revision = "0017"
down_revision = "0016"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"bill_votes",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("bill_id", sa.String, sa.ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False),
sa.Column("congress", sa.Integer, nullable=False),
sa.Column("chamber", sa.String(50), nullable=False),
sa.Column("session", sa.Integer, nullable=False),
sa.Column("roll_number", sa.Integer, nullable=False),
sa.Column("question", sa.Text),
sa.Column("description", sa.Text),
sa.Column("vote_date", sa.Date),
sa.Column("yeas", sa.Integer),
sa.Column("nays", sa.Integer),
sa.Column("not_voting", sa.Integer),
sa.Column("result", sa.String(200)),
sa.Column("source_url", sa.String),
sa.Column("fetched_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_index("ix_bill_votes_bill_id", "bill_votes", ["bill_id"])
op.create_unique_constraint(
"uq_bill_votes_roll",
"bill_votes",
["congress", "chamber", "session", "roll_number"],
)
op.create_table(
"member_vote_positions",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("vote_id", sa.Integer, sa.ForeignKey("bill_votes.id", ondelete="CASCADE"), nullable=False),
sa.Column("bioguide_id", sa.String, sa.ForeignKey("members.bioguide_id", ondelete="SET NULL"), nullable=True),
sa.Column("member_name", sa.String(200)),
sa.Column("party", sa.String(50)),
sa.Column("state", sa.String(10)),
sa.Column("position", sa.String(50), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_index("ix_member_vote_positions_vote_id", "member_vote_positions", ["vote_id"])
op.create_index("ix_member_vote_positions_bioguide_id", "member_vote_positions", ["bioguide_id"])
def downgrade():
op.drop_index("ix_member_vote_positions_bioguide_id", "member_vote_positions")
op.drop_index("ix_member_vote_positions_vote_id", "member_vote_positions")
op.drop_table("member_vote_positions")
op.drop_index("ix_bill_votes_bill_id", "bill_votes")
op.drop_constraint("uq_bill_votes_roll", "bill_votes")
op.drop_table("bill_votes")