Skip to content

Commit ddd5c4a

Browse files
EarthenSkyp-north
andauthored
Second Elections PR (#105)
* add urls.py functions for registration, add empty crud functions, update table names * add election status & getting all elections * add position to application pkey & implement crud * add avaliable positions & make it configurable. clean code too * registrations can only be made during the nomination period, and check if position is active * add support for nominee info * update names * fix foreign key bug * fix db null value issue when resetting test database * fix sqlalchemy syntax error * add test cases for election endpoints * add mock test data to testDB * cleanup endpoint logic * upgrade alembic head * fix ruff linting issues * fix test file linting * add additional test cases and cleanup endpoints * update endpoint urls * linting fixes * code cleanup * minor word corrections * minor logic tweak * deprecate update_registration endpoints * cleanup linting errors * minor tweaks * add back patch registration endpoint and uodate logic * update endpoint logic --------- Co-authored-by: p-north <[email protected]>
1 parent f765ac9 commit ddd5c4a

File tree

7 files changed

+1070
-139
lines changed

7 files changed

+1070
-139
lines changed

src/alembic/versions/243190df5588_create_election_tables.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,34 +28,33 @@ def upgrade() -> None:
2828
sa.Column("datetime_start_nominations", sa.DateTime(), nullable=False),
2929
sa.Column("datetime_start_voting", sa.DateTime(), nullable=False),
3030
sa.Column("datetime_end_voting", sa.DateTime(), nullable=False),
31+
sa.Column("available_positions", sa.Text(), nullable=False),
3132
sa.Column("survey_link", sa.String(length=300), nullable=True),
3233
sa.PrimaryKeyConstraint("slug")
3334
)
3435
op.create_table(
35-
"election_nominee",
36+
"election_nominee_info",
3637
sa.Column("computing_id", sa.String(length=32), nullable=False),
3738
sa.Column("full_name", sa.String(length=64), nullable=False),
38-
sa.Column("facebook", sa.String(length=128), nullable=True),
39+
sa.Column("linked_in", sa.String(length=128), nullable=True),
3940
sa.Column("instagram", sa.String(length=128), nullable=True),
4041
sa.Column("email", sa.String(length=64), nullable=True),
41-
sa.Column("discord", sa.String(length=32), nullable=True),
42-
sa.Column("discord_id", sa.String(length=32), nullable=True),
4342
sa.Column("discord_username", sa.String(length=32), nullable=True),
4443
sa.PrimaryKeyConstraint("computing_id")
4544
)
4645
op.create_table(
47-
"nominee_application",
46+
"election_nominee_application",
4847
sa.Column("computing_id", sa.String(length=32), nullable=False),
49-
sa.Column("nominee_election", sa.String(length=32), nullable=False),
50-
sa.Column("speech", sa.Text(), nullable=True),
48+
sa.Column("nominee_election", sa.String(length=64), nullable=False),
5149
sa.Column("position", sa.String(length=64), nullable=False),
52-
sa.ForeignKeyConstraint(["computing_id"], ["election_nominee.computing_id"]),
50+
sa.Column("speech", sa.Text(), nullable=True),
51+
sa.ForeignKeyConstraint(["computing_id"], ["election_nominee_info.computing_id"]),
5352
sa.ForeignKeyConstraint(["nominee_election"], ["election.slug"]),
54-
sa.PrimaryKeyConstraint("computing_id", "nominee_election")
53+
sa.PrimaryKeyConstraint("computing_id", "nominee_election", "position")
5554
)
5655

5756

5857
def downgrade() -> None:
59-
op.drop_table("nominee_application")
60-
op.drop_table("election_nominee")
58+
op.drop_table("election_nominee_application")
59+
op.drop_table("election_nominee_info")
6160
op.drop_table("election")

src/elections/crud.py

Lines changed: 117 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,49 +3,146 @@
33
import sqlalchemy
44
from sqlalchemy.ext.asyncio import AsyncSession
55

6-
from elections.tables import Election
6+
from elections.tables import Election, NomineeApplication, NomineeInfo
77

88
_logger = logging.getLogger(__name__)
99

10+
async def get_all_elections(db_session: AsyncSession) -> list[Election] | None:
11+
# TODO: can this return None?
12+
election_list = (await db_session.scalars(
13+
sqlalchemy
14+
.select(Election)
15+
)).all()
16+
return election_list
17+
1018
async def get_election(db_session: AsyncSession, election_slug: str) -> Election | None:
1119
return await db_session.scalar(
1220
sqlalchemy
1321
.select(Election)
1422
.where(Election.slug == election_slug)
1523
)
1624

17-
async def create_election(db_session: AsyncSession, election: Election) -> None:
25+
async def create_election(db_session: AsyncSession, election: Election):
1826
"""
1927
Creates a new election with given parameters.
2028
Does not validate if an election _already_ exists
2129
"""
2230
db_session.add(election)
2331

32+
async def update_election(db_session: AsyncSession, new_election: Election):
33+
"""
34+
Attempting to change slug will fail. Instead, you must create a new election.
35+
"""
36+
await db_session.execute(
37+
sqlalchemy
38+
.update(Election)
39+
.where(Election.slug == new_election.slug)
40+
.values(new_election.to_update_dict())
41+
)
42+
2443
async def delete_election(db_session: AsyncSession, slug: str) -> None:
2544
"""
26-
Deletes a given election by its slug.
27-
Does not validate if an election exists
45+
Deletes a given election by its slug. Does not validate if an election exists
2846
"""
2947
await db_session.execute(
3048
sqlalchemy
3149
.delete(Election)
3250
.where(Election.slug == slug)
3351
)
3452

35-
async def update_election(db_session: AsyncSession, new_election: Election) -> bool:
36-
"""
37-
You attempting to change the name or slug will fail. Instead, you must create a new election.
38-
"""
39-
target_slug = new_election.slug
40-
target_election = await get_election(db_session, target_slug)
41-
42-
if target_election is None:
43-
return False
44-
else:
45-
await db_session.execute(
46-
sqlalchemy
47-
.update(Election)
48-
.where(Election.slug == target_slug)
49-
.values(new_election.to_update_dict())
53+
# ------------------------------------------------------- #
54+
55+
# TODO: switch to only using one of application or registration
56+
async def get_all_registrations(
57+
db_session: AsyncSession,
58+
computing_id: str,
59+
election_slug: str
60+
) -> list[NomineeApplication] | None:
61+
registrations = (await db_session.scalars(
62+
sqlalchemy
63+
.select(NomineeApplication)
64+
.where(
65+
(NomineeApplication.computing_id == computing_id)
66+
& (NomineeApplication.nominee_election == election_slug)
5067
)
51-
return True
68+
)).all()
69+
return registrations
70+
71+
async def get_all_registrations_in_election(
72+
db_session: AsyncSession,
73+
election_slug: str,
74+
) -> list[NomineeApplication] | None:
75+
registrations = (await db_session.scalars(
76+
sqlalchemy
77+
.select(NomineeApplication)
78+
.where(
79+
NomineeApplication.nominee_election == election_slug
80+
)
81+
)).all()
82+
return registrations
83+
84+
async def add_registration(
85+
db_session: AsyncSession,
86+
initial_application: NomineeApplication
87+
):
88+
db_session.add(initial_application)
89+
90+
async def update_registration(
91+
db_session: AsyncSession,
92+
initial_application: NomineeApplication
93+
):
94+
await db_session.execute(
95+
sqlalchemy
96+
.update(NomineeApplication)
97+
.where(
98+
(NomineeApplication.computing_id == initial_application.computing_id)
99+
& (NomineeApplication.nominee_election == initial_application.nominee_election)
100+
& (NomineeApplication.position == initial_application.position)
101+
)
102+
.values(initial_application.to_update_dict())
103+
)
104+
105+
async def delete_registration(
106+
db_session: AsyncSession,
107+
computing_id: str,
108+
election_slug: str,
109+
position: str
110+
):
111+
await db_session.execute(
112+
sqlalchemy
113+
.delete(NomineeApplication)
114+
.where(
115+
(NomineeApplication.computing_id == computing_id)
116+
& (NomineeApplication.nominee_election == election_slug)
117+
& (NomineeApplication.position == position)
118+
)
119+
)
120+
121+
# ------------------------------------------------------- #
122+
123+
async def get_nominee_info(
124+
db_session: AsyncSession,
125+
computing_id: str,
126+
) -> NomineeInfo | None:
127+
return await db_session.scalar(
128+
sqlalchemy
129+
.select(NomineeInfo)
130+
.where(NomineeInfo.computing_id == computing_id)
131+
)
132+
133+
async def create_nominee_info(
134+
db_session: AsyncSession,
135+
info: NomineeInfo,
136+
):
137+
db_session.add(info)
138+
139+
async def update_nominee_info(
140+
db_session: AsyncSession,
141+
info: NomineeInfo,
142+
):
143+
await db_session.execute(
144+
sqlalchemy
145+
.update(NomineeInfo)
146+
.where(NomineeInfo.computing_id == info.computing_id)
147+
.values(info.to_update_dict())
148+
)

src/elections/tables.py

Lines changed: 104 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import datetime
2+
13
from sqlalchemy import (
24
Column,
35
DateTime,
@@ -14,9 +16,20 @@
1416
DISCORD_NICKNAME_LEN,
1517
)
1618
from database import Base
19+
from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS
1720

21+
# If you wish to add more elections & defaults, please see `create_election`
1822
election_types = ["general_election", "by_election", "council_rep_election"]
1923

24+
DEFAULT_POSITIONS_GENERAL_ELECTION = ",".join(GENERAL_ELECTION_POSITIONS)
25+
DEFAULT_POSITIONS_BY_ELECTION = ",".join(GENERAL_ELECTION_POSITIONS)
26+
DEFAULT_POSITIONS_COUNCIL_REP_ELECTION = ",".join(COUNCIL_REP_ELECTION_POSITIONS)
27+
28+
STATUS_BEFORE_NOMINATIONS = "before_nominations"
29+
STATUS_NOMINATIONS = "nominations"
30+
STATUS_VOTING = "voting"
31+
STATUS_AFTER_VOTING = "after_voting"
32+
2033
MAX_ELECTION_NAME = 64
2134
MAX_ELECTION_SLUG = 64
2235

@@ -30,9 +43,13 @@ class Election(Base):
3043
datetime_start_nominations = Column(DateTime, nullable=False)
3144
datetime_start_voting = Column(DateTime, nullable=False)
3245
datetime_end_voting = Column(DateTime, nullable=False)
46+
47+
# a csv list of positions which must be elements of OfficerPosition
48+
available_positions = Column(Text, nullable=False)
3349
survey_link = Column(String(300))
3450

35-
def serializable_dict(self) -> dict:
51+
def private_details(self, at_time: datetime) -> dict:
52+
# is serializable
3653
return {
3754
"slug": self.slug,
3855
"name": self.name,
@@ -42,10 +59,28 @@ def serializable_dict(self) -> dict:
4259
"datetime_start_voting": self.datetime_start_voting.isoformat(),
4360
"datetime_end_voting": self.datetime_end_voting.isoformat(),
4461

62+
"status": self.status(at_time),
63+
"available_positions": self.available_positions,
4564
"survey_link": self.survey_link,
4665
}
4766

48-
def public_details(self) -> dict:
67+
def public_details(self, at_time: datetime) -> dict:
68+
# is serializable
69+
return {
70+
"slug": self.slug,
71+
"name": self.name,
72+
"type": self.type,
73+
74+
"datetime_start_nominations": self.datetime_start_nominations.isoformat(),
75+
"datetime_start_voting": self.datetime_start_voting.isoformat(),
76+
"datetime_end_voting": self.datetime_end_voting.isoformat(),
77+
78+
"status": self.status(at_time),
79+
"available_positions": self.available_positions,
80+
}
81+
82+
def public_metadata(self, at_time: datetime) -> dict:
83+
# is serializable
4984
return {
5085
"slug": self.slug,
5186
"name": self.name,
@@ -54,6 +89,8 @@ def public_details(self) -> dict:
5489
"datetime_start_nominations": self.datetime_start_nominations.isoformat(),
5590
"datetime_start_voting": self.datetime_start_voting.isoformat(),
5691
"datetime_end_voting": self.datetime_end_voting.isoformat(),
92+
93+
"status": self.status(at_time),
5794
}
5895

5996
def to_update_dict(self) -> dict:
@@ -66,31 +103,85 @@ def to_update_dict(self) -> dict:
66103
"datetime_start_voting": self.datetime_start_voting,
67104
"datetime_end_voting": self.datetime_end_voting,
68105

106+
"available_positions": self.available_positions,
69107
"survey_link": self.survey_link,
70108
}
71109

72-
# Each row represents a nominee of a given election
73-
class Nominee(Base):
74-
__tablename__ = "election_nominee"
110+
def status(self, at_time: datetime) -> str:
111+
if at_time <= self.datetime_start_nominations:
112+
return STATUS_BEFORE_NOMINATIONS
113+
elif at_time <= self.datetime_start_voting:
114+
return STATUS_NOMINATIONS
115+
elif at_time <= self.datetime_end_voting:
116+
return STATUS_VOTING
117+
else:
118+
return STATUS_AFTER_VOTING
119+
120+
class NomineeInfo(Base):
121+
__tablename__ = "election_nominee_info"
75122

76-
# Previously named sfuid
77123
computing_id = Column(String(COMPUTING_ID_LEN), primary_key=True)
78124
full_name = Column(String(64), nullable=False)
79-
facebook = Column(String(128))
125+
linked_in = Column(String(128))
80126
instagram = Column(String(128))
81127
email = Column(String(64))
82-
discord = Column(String(DISCORD_NAME_LEN))
83-
discord_id = Column(String(DISCORD_ID_LEN))
84128
discord_username = Column(String(DISCORD_NICKNAME_LEN))
85129

130+
def to_update_dict(self) -> dict:
131+
return {
132+
"computing_id": self.computing_id,
133+
"full_name": self.full_name,
134+
135+
"linked_in": self.linked_in,
136+
"instagram": self.instagram,
137+
"email": self.email,
138+
"discord_username": self.discord_username,
139+
}
140+
141+
def as_serializable(self) -> dict:
142+
# NOTE: this function is currently the same as to_update_dict since the contents
143+
# have a different invariant they're upholding, which may cause them to change if a
144+
# new property is introduced. For example, dates must be converted into strings
145+
# to be serialized, but must not for update dictionaries.
146+
return {
147+
"computing_id": self.computing_id,
148+
"full_name": self.full_name,
149+
150+
"linked_in": self.linked_in,
151+
"instagram": self.instagram,
152+
"email": self.email,
153+
"discord_username": self.discord_username,
154+
}
155+
86156
class NomineeApplication(Base):
87-
__tablename__ = "nominee_application"
157+
__tablename__ = "election_nominee_application"
88158

89-
computing_id = Column(ForeignKey("election_nominee.computing_id"), primary_key=True)
159+
# TODO: add index for nominee_election?
160+
computing_id = Column(ForeignKey("election_nominee_info.computing_id"), primary_key=True)
90161
nominee_election = Column(ForeignKey("election.slug"), primary_key=True)
162+
position = Column(String(64), primary_key=True)
163+
91164
speech = Column(Text)
92-
position = Column(String(64), nullable=False)
93165

94166
__table_args__ = (
95-
PrimaryKeyConstraint(computing_id, nominee_election),
167+
PrimaryKeyConstraint(computing_id, nominee_election, position),
96168
)
169+
170+
def serializable_dict(self) -> dict:
171+
return {
172+
"computing_id": self.computing_id,
173+
"nominee_election": self.nominee_election,
174+
"position": self.position,
175+
176+
"speech": self.speech,
177+
}
178+
179+
def to_update_dict(self) -> dict:
180+
return {
181+
"computing_id": self.computing_id,
182+
"nominee_election": self.nominee_election,
183+
"position": self.position,
184+
185+
"speech": self.speech,
186+
}
187+

0 commit comments

Comments
 (0)