Skip to content

Commit a6b60c1

Browse files
committed
fix: marshalling available positions from str to list[str]
1 parent d3a02e8 commit a6b60c1

File tree

5 files changed

+42
-27
lines changed

5 files changed

+42
-27
lines changed

src/elections/tables.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
String,
88
Text,
99
)
10-
from sqlalchemy.ext.hybrid import hybrid_property
1110
from sqlalchemy.orm import Mapped, mapped_column
1211

1312
from constants import (
@@ -21,6 +20,7 @@
2120
NomineeApplicationUpdateParams,
2221
)
2322
from officers.types import OfficerPositionEnum
23+
from utils.types import StringList
2424

2525
MAX_ELECTION_NAME = 64
2626
MAX_ELECTION_SLUG = 64
@@ -36,20 +36,13 @@ class Election(Base):
3636
datetime_start_voting: Mapped[datetime] = mapped_column(DateTime, nullable=False)
3737
datetime_end_voting: Mapped[datetime] = mapped_column(DateTime, nullable=False)
3838

39-
# a csv list of positions which must be elements of OfficerPosition
40-
_available_positions: Mapped[str] = mapped_column("available_positions", Text, nullable=False)
39+
# a comma-separated string of positions which must be elements of OfficerPosition
40+
# By giving it the type `StringList`, the database entry will automatically be marshalled to the correct form
41+
# DB -> Python: str -> list[str]
42+
# Python -> DB: list[str] -> str
43+
available_positions: Mapped[list[OfficerPositionEnum]] = mapped_column(StringList(), nullable=False,)
4144
survey_link: Mapped[str | None] = mapped_column(String(300))
4245

43-
@hybrid_property
44-
def available_positions(self) -> str: # pyright: ignore
45-
return self._available_positions
46-
47-
@available_positions.setter
48-
def available_positions(self, value: str | list[str]) -> None:
49-
if isinstance(value, list):
50-
value = ",".join(value)
51-
self._available_positions = value
52-
5346
def private_details(self, at_time: datetime) -> dict:
5447
# is serializable
5548
return {
@@ -62,7 +55,7 @@ def private_details(self, at_time: datetime) -> dict:
6255
"datetime_end_voting": self.datetime_end_voting.isoformat(),
6356

6457
"status": self.status(at_time),
65-
"available_positions": self._available_positions,
58+
"available_positions": self.available_positions,
6659
"survey_link": self.survey_link,
6760
}
6861

@@ -78,7 +71,7 @@ def public_details(self, at_time: datetime) -> dict:
7871
"datetime_end_voting": self.datetime_end_voting.isoformat(),
7972

8073
"status": self.status(at_time),
81-
"available_positions": self._available_positions,
74+
"available_positions": self.available_positions,
8275
}
8376

8477
def public_metadata(self, at_time: datetime) -> dict:
@@ -105,7 +98,7 @@ def to_update_dict(self) -> dict:
10598
"datetime_start_voting": self.datetime_start_voting.date(),
10699
"datetime_end_voting": self.datetime_end_voting.date(),
107100

108-
"available_positions": self._available_positions,
101+
"available_positions": self.available_positions,
109102
"survey_link": self.survey_link,
110103
}
111104

src/elections/urls.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def _raise_if_bad_election_data(
6767
datetime_start_nominations: datetime,
6868
datetime_start_voting: datetime,
6969
datetime_end_voting: datetime,
70-
available_positions: str
70+
available_positions: list[str]
7171
):
7272
if election_type not in ElectionTypeEnum:
7373
raise HTTPException(
@@ -81,7 +81,7 @@ def _raise_if_bad_election_data(
8181
detail="dates must be in order from earliest to latest",
8282
)
8383

84-
for position in available_positions.split(","):
84+
for position in available_positions:
8585
if position not in OfficerPositionEnum:
8686
raise HTTPException(
8787
status_code=status.HTTP_400_BAD_REQUEST,
@@ -170,7 +170,7 @@ async def get_election(
170170
)
171171
election_json["candidates"] = []
172172

173-
available_positions_list = election._available_positions.split(",")
173+
available_positions_list = election.available_positions
174174
for nomination in all_nominations:
175175
if nomination.position not in available_positions_list:
176176
# ignore any positions that are **no longer** active
@@ -243,7 +243,7 @@ async def create_election(
243243
start_nominations,
244244
start_voting,
245245
end_voting,
246-
",".join(available_positions),
246+
available_positions
247247
)
248248

249249
is_valid_user, _, _ = await _get_user_permissions(request, db_session)
@@ -330,7 +330,7 @@ async def update_election(
330330
election.datetime_start_voting,
331331
election.datetime_start_voting,
332332
election.datetime_end_voting,
333-
election._available_positions,
333+
election.available_positions,
334334
)
335335

336336
# NOTE: If you update available positions, people will still *technically* be able to update their
@@ -449,7 +449,7 @@ async def register_in_election(
449449
detail=f"election with slug {slugified_name} does not exist"
450450
)
451451

452-
if body.position not in election._available_positions.split(","):
452+
if body.position not in election.available_positions:
453453
# NOTE: We only restrict creating a registration for a position that doesn't exist,
454454
# not updating or deleting one
455455
raise HTTPException(

src/load_test_db.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ async def load_test_elections_data(db_session: AsyncSession):
313313
datetime_start_nominations=datetime.now() - timedelta(days=400),
314314
datetime_start_voting=datetime.now() - timedelta(days=395, hours=4),
315315
datetime_end_voting=datetime.now() - timedelta(days=390, hours=8),
316-
available_positions="president,vice-president,treasurer",
316+
available_positions=["president", "vice-president", "treasurer"],
317317
survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5"
318318
))
319319
await create_election(db_session, Election(
@@ -323,7 +323,7 @@ async def load_test_elections_data(db_session: AsyncSession):
323323
datetime_start_nominations=datetime.now() - timedelta(days=1),
324324
datetime_start_voting=datetime.now() + timedelta(days=7),
325325
datetime_end_voting=datetime.now() + timedelta(days=14),
326-
available_positions="president,vice-president,treasurer",
326+
available_positions=["president", "vice-president", "treasurer"],
327327
survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5 (oh yeah)"
328328
))
329329
await create_nominee_info(db_session, NomineeInfo(
@@ -349,7 +349,7 @@ async def load_test_elections_data(db_session: AsyncSession):
349349
datetime_start_nominations=datetime.now() - timedelta(days=5),
350350
datetime_start_voting=datetime.now() - timedelta(days=1, hours=4),
351351
datetime_end_voting=datetime.now() + timedelta(days=5, hours=8),
352-
available_positions="president,vice-president,treasurer",
352+
available_positions=["president", "vice-president" ,"treasurer"],
353353
survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5"
354354
))
355355
await create_election(db_session, Election(
@@ -359,7 +359,7 @@ async def load_test_elections_data(db_session: AsyncSession):
359359
datetime_start_nominations=datetime.now() + timedelta(days=5),
360360
datetime_start_voting=datetime.now() + timedelta(days=10, hours=4),
361361
datetime_end_voting=datetime.now() + timedelta(days=15, hours=8),
362-
available_positions="president,vice-president,treasurer",
362+
available_positions=["president" ,"vice-president", "treasurer"],
363363
survey_link=None
364364
))
365365
await db_session.commit()

src/utils/types.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
2+
from sqlalchemy import Dialect
3+
from sqlalchemy.types import Text, TypeDecorator
4+
5+
6+
class StringList(TypeDecorator):
7+
impl = Text
8+
cache_ok = True
9+
10+
def process_bind_param(self, value, dialect: Dialect) -> str:
11+
if value is None:
12+
return ""
13+
if not isinstance(value, list):
14+
raise ValueError("Must be a list")
15+
16+
return ",".join(value)
17+
18+
def process_result_value(self, value, dialect: Dialect) -> list[str] | None:
19+
if value is None or value == "":
20+
return []
21+
return value.split(",")
22+
23+

tests/integration/test_elections.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ async def test_endpoints(client, database_setup):
108108
assert response.status_code == 401
109109

110110
response = await client.post("/elections", json={
111-
"slug": _slugify(election_name),
112111
"name": election_name,
113112
"type": "general_election",
114113
"datetime_start_nominations": "2025-08-18T09:00:00Z",

0 commit comments

Comments
 (0)