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 <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2022-03-07 18:16:41 -08:00
parent c7c79a152b
commit a29701459c
No known key found for this signature in database
GPG key ID: F7E46DED420788F3
4 changed files with 95 additions and 4 deletions

View file

@ -6,6 +6,17 @@ from aurweb.models.declarative import Base
from aurweb.models.tu_voteinfo import TUVoteInfo as _TUVoteInfo from aurweb.models.tu_voteinfo import TUVoteInfo as _TUVoteInfo
from aurweb.models.user import User as _User 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): class TUVote(Base):
__table__ = schema.TU_Votes __table__ = schema.TU_Votes
@ -29,10 +40,17 @@ class TUVote(Base):
raise IntegrityError( raise IntegrityError(
statement="Foreign key VoteID cannot be null.", statement="Foreign key VoteID cannot be null.",
orig="TU_Votes.VoteID", orig="TU_Votes.VoteID",
params=("NULL")) params=("NULL",))
if not self.User and not self.UserID: if not self.User and not self.UserID:
raise IntegrityError( raise IntegrityError(
statement="Foreign key UserID cannot be null.", statement="Foreign key UserID cannot be null.",
orig="TU_Votes.UserID", 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,))

View file

@ -406,6 +406,7 @@ TU_Votes = Table(
'TU_Votes', metadata, 'TU_Votes', metadata,
Column('VoteID', ForeignKey('TU_VoteInfo.ID', ondelete='CASCADE'), nullable=False), Column('VoteID', ForeignKey('TU_VoteInfo.ID', ondelete='CASCADE'), nullable=False),
Column('UserID', ForeignKey('Users.ID', ondelete='CASCADE'), nullable=False), Column('UserID', ForeignKey('Users.ID', ondelete='CASCADE'), nullable=False),
Column('Decision', TINYINT(unsigned=True)),
mysql_engine='InnoDB', mysql_engine='InnoDB',
) )

View file

@ -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")

View file

@ -4,7 +4,7 @@ from sqlalchemy.exc import IntegrityError
from aurweb import db, time from aurweb import db, time
from aurweb.models.account_type import TRUSTED_USER_ID 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.tu_voteinfo import TUVoteInfo
from aurweb.models.user import User 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): def test_tu_vote_creation(user: User, tu_voteinfo: TUVoteInfo):
with db.begin(): 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.VoteInfo == tu_voteinfo
assert tu_vote.User == user assert tu_vote.User == user
assert tu_vote.Decision == YES_ID
assert tu_vote in user.tu_votes assert tu_vote in user.tu_votes
assert tu_vote in tu_voteinfo.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): def test_tu_vote_null_voteinfo_raises_exception(user: User):
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
TUVote(User=user) 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)