From a29701459c0364a8059ed164ff9547f52f44cb47 Mon Sep 17 00:00:00 2001 From: Kevin Morris Date: Mon, 7 Mar 2022 18:16:41 -0800 Subject: [PATCH] feat: add Decision column to TUVote In preparation for allowing TUs to change their votes on proposals, we need a way to track what users vote for. Without this, the vote decisions are stored within the related TU_VoteInfo record, decoupled from the user who made the vote. That being the case meant we cannot actually change a vote, because we can't figure out what TU_VoteInfo decision columns to decrement when the vote has changed. You may be wondering why we aren't removing the decision columns out of TU_VoteInfo with the advent of this new column. The reason being: previous votes are all calculated off of the TU_VoteInfo columns, so without a manual DB rework, relocating them to the user-related vote records would break outcomes of old proposals. So, the plan is: we'll solely use this column for votes from this point on to track what decision a user made internally, which will open up TUs changing their decision. In addition, this migration resets all running proposals: - all votes are deleted - time is reset to Start when migration is run This was necessary to put running proposals into a state that can take advantage of the new revote system. Signed-off-by: Kevin Morris --- aurweb/models/tu_vote.py | 22 ++++++- aurweb/schema.py | 1 + ...dde_relocate_vote_decisions_into_tuvote.py | 61 +++++++++++++++++++ test/test_tu_vote.py | 15 ++++- 4 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 migrations/versions/84cd90073dde_relocate_vote_decisions_into_tuvote.py diff --git a/aurweb/models/tu_vote.py b/aurweb/models/tu_vote.py index efb23b19..cd486b4d 100644 --- a/aurweb/models/tu_vote.py +++ b/aurweb/models/tu_vote.py @@ -6,6 +6,17 @@ from aurweb.models.declarative import Base from aurweb.models.tu_voteinfo import TUVoteInfo as _TUVoteInfo from aurweb.models.user import User as _User +YES_ID = 1 +NO_ID = 2 +ABSTAIN_ID = 3 + +DECISIONS = { + YES_ID: "Yes", + NO_ID: "No", + ABSTAIN_ID: "Abstain", +} +DECISION_IDS = {v: k for k, v in DECISIONS.items()} + class TUVote(Base): __table__ = schema.TU_Votes @@ -29,10 +40,17 @@ class TUVote(Base): raise IntegrityError( statement="Foreign key VoteID cannot be null.", orig="TU_Votes.VoteID", - params=("NULL")) + params=("NULL",)) if not self.User and not self.UserID: raise IntegrityError( statement="Foreign key UserID cannot be null.", orig="TU_Votes.UserID", - params=("NULL")) + params=("NULL",)) + + if self.Decision is None or self.Decision not in DECISIONS: + raise IntegrityError( + statement=("Decision column must be a valid key in " + "aurweb.models.tu_vote.DECISIONS"), + orig="TU_Votes.Decision", + params=(self.Decision,)) diff --git a/aurweb/schema.py b/aurweb/schema.py index d2644541..3fc2356b 100644 --- a/aurweb/schema.py +++ b/aurweb/schema.py @@ -406,6 +406,7 @@ TU_Votes = Table( 'TU_Votes', metadata, Column('VoteID', ForeignKey('TU_VoteInfo.ID', ondelete='CASCADE'), nullable=False), Column('UserID', ForeignKey('Users.ID', ondelete='CASCADE'), nullable=False), + Column('Decision', TINYINT(unsigned=True)), mysql_engine='InnoDB', ) diff --git a/migrations/versions/84cd90073dde_relocate_vote_decisions_into_tuvote.py b/migrations/versions/84cd90073dde_relocate_vote_decisions_into_tuvote.py new file mode 100644 index 00000000..19be49f8 --- /dev/null +++ b/migrations/versions/84cd90073dde_relocate_vote_decisions_into_tuvote.py @@ -0,0 +1,61 @@ +"""relocate vote decisions into TUVote + +Revision ID: 84cd90073dde +Revises: d64e5571bc8d +Create Date: 2022-03-07 18:02:28.791103 + +This migration allows us to give Trusted Users the ability to +modify a vote they made on a proposal. Previously, the decision +was tracked purely through TU_VoteInfo records, which removes +tracking of what decisions users made. This blocks us from being +able to modify votes, because we can't update the TU_VoteInfo +Yes, No or Abstain columns properly. + +""" +import sqlalchemy as sa + +from alembic import op +from sqlalchemy.dialects.mysql import TINYINT + +from aurweb import db, logging, time +from aurweb.models import TUVoteInfo + +logger = logging.get_logger("alembic") + +# revision identifiers, used by Alembic. +revision = '84cd90073dde' +down_revision = 'd64e5571bc8d' +branch_labels = None +depends_on = None + +TABLE = "TU_Votes" + + +def upgrade(): + decision = sa.Column("Decision", TINYINT(unsigned=True)) + op.add_column(TABLE, decision) + + # For each proposal which is running at the time this migration + # is applied, eradicate all related votes. We will restart the votes. + # In addition, reset the Submitted and End columns based on the current + # timestamp; essentially resetting the proposal's state. + utcnow = time.utcnow() + running_proposals = db.query(TUVoteInfo).filter(TUVoteInfo.End > utcnow) + with db.begin(): + for proposal in running_proposals: + logger.info(f"Resetting proposal with ID {proposal.ID}: " + "Yes = 0, No = 0, Abstain = 0, deleted vote records") + + # Reset the Submitted and End columns. + length = proposal.End - proposal.Submitted + proposal.Submitted = utcnow + proposal.End = utcnow + length + + proposal.Yes = proposal.No = proposal.Abstain = 0 + db.delete_all(proposal.tu_votes) + logger.info(f"Proposal time range was reset: Submitted = " + f"{proposal.Submitted} and End = {proposal.End}") + + +def downgrade(): + op.drop_column(TABLE, "Decision") diff --git a/test/test_tu_vote.py b/test/test_tu_vote.py index 91d73ecb..3956e07e 100644 --- a/test/test_tu_vote.py +++ b/test/test_tu_vote.py @@ -4,7 +4,7 @@ from sqlalchemy.exc import IntegrityError from aurweb import db, time from aurweb.models.account_type import TRUSTED_USER_ID -from aurweb.models.tu_vote import TUVote +from aurweb.models.tu_vote import YES_ID, TUVote from aurweb.models.tu_voteinfo import TUVoteInfo from aurweb.models.user import User @@ -36,10 +36,12 @@ def tu_voteinfo(user: User) -> TUVoteInfo: def test_tu_vote_creation(user: User, tu_voteinfo: TUVoteInfo): with db.begin(): - tu_vote = db.create(TUVote, User=user, VoteInfo=tu_voteinfo) + tu_vote = db.create(TUVote, User=user, VoteInfo=tu_voteinfo, + Decision=YES_ID) assert tu_vote.VoteInfo == tu_voteinfo assert tu_vote.User == user + assert tu_vote.Decision == YES_ID assert tu_vote in user.tu_votes assert tu_vote in tu_voteinfo.tu_votes @@ -52,3 +54,12 @@ def test_tu_vote_null_user_raises_exception(tu_voteinfo: TUVoteInfo): def test_tu_vote_null_voteinfo_raises_exception(user: User): with pytest.raises(IntegrityError): TUVote(User=user) + + +def test_tu_vote_bad_decision_raises_exception(user: User, + tu_voteinfo: TUVoteInfo): + with pytest.raises(IntegrityError): + TUVote(User=user, VoteInfo=tu_voteinfo, Decision=None) + + with pytest.raises(IntegrityError): + TUVote(User=user, VoteInfo=tu_voteinfo, Decision=0)