Skip to content

Commit 6e5c539

Browse files
authored
Update: Various Elections API changes, Part 2 (#118)
* Updated the metadata for the elections endpoints so that our docs are accurate * Singularized the election endpoints and moved the election endpoints to their own root path * `/elections` -> `/election` * `/elections/registration/` -> `/registration` * `/elections/nominee_info/` -> `/nominee` * This enforces a pattern where each table has its own set of operations. We can always make endpoints that do more complicated things, which compose multiple operations in a single call. * Changed GET `/elections/list` -> GET `/election` * Removed the constraint that elections can't be named "list" * Updated all PATCH endpoints to the following pattern: * The URL params are used to target a specific database entry * The body holds the values that will be used to update the entry * The entry's fields will only be updated if there is an explicit value set in the body (so a `body.speech` that's `NULL` will set it to `NULL` in the database, but if `body.speech` doesn't exist then the entry's value will not change) * Updated all the POST endpoints to use the body as the entry's values * DELETE endpoints target a single entry in the database using the URL parameters * Made some endpoints require you to be an admin * All current elections unit tests should all pass * Made enums for officer positions and election statuses * `available_positions` will automatically be converted to a `str` when saved to the database and to a `list[str]` when used in the Python code * Moved nominee-application-related things to the `registrations` folder * Moved nominee-info-related things to the `nominees` folder
1 parent 58b2094 commit 6e5c539

File tree

23 files changed

+1212
-947
lines changed

23 files changed

+1212
-947
lines changed

src/elections/crud.py

Lines changed: 4 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
from collections.abc import Sequence
2+
13
import sqlalchemy
24
from sqlalchemy.ext.asyncio import AsyncSession
35

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

68

7-
async def get_all_elections(db_session: AsyncSession) -> list[Election]:
8-
# TODO: can this return None?
9+
async def get_all_elections(db_session: AsyncSession) -> Sequence[Election]:
910
election_list = (await db_session.scalars(
1011
sqlalchemy
1112
.select(Election)
@@ -46,100 +47,3 @@ async def delete_election(db_session: AsyncSession, slug: str) -> None:
4647
.delete(Election)
4748
.where(Election.slug == slug)
4849
)
49-
50-
# ------------------------------------------------------- #
51-
52-
# TODO: switch to only using one of application or registration
53-
async def get_all_registrations_of_user(
54-
db_session: AsyncSession,
55-
computing_id: str,
56-
election_slug: str
57-
) -> list[NomineeApplication] | None:
58-
registrations = (await db_session.scalars(
59-
sqlalchemy
60-
.select(NomineeApplication)
61-
.where(
62-
(NomineeApplication.computing_id == computing_id)
63-
& (NomineeApplication.nominee_election == election_slug)
64-
)
65-
)).all()
66-
return registrations
67-
68-
async def get_all_registrations_in_election(
69-
db_session: AsyncSession,
70-
election_slug: str,
71-
) -> list[NomineeApplication] | None:
72-
registrations = (await db_session.scalars(
73-
sqlalchemy
74-
.select(NomineeApplication)
75-
.where(
76-
NomineeApplication.nominee_election == election_slug
77-
)
78-
)).all()
79-
return registrations
80-
81-
async def add_registration(
82-
db_session: AsyncSession,
83-
initial_application: NomineeApplication
84-
):
85-
db_session.add(initial_application)
86-
87-
async def update_registration(
88-
db_session: AsyncSession,
89-
initial_application: NomineeApplication
90-
):
91-
await db_session.execute(
92-
sqlalchemy
93-
.update(NomineeApplication)
94-
.where(
95-
(NomineeApplication.computing_id == initial_application.computing_id)
96-
& (NomineeApplication.nominee_election == initial_application.nominee_election)
97-
& (NomineeApplication.position == initial_application.position)
98-
)
99-
.values(initial_application.to_update_dict())
100-
)
101-
102-
async def delete_registration(
103-
db_session: AsyncSession,
104-
computing_id: str,
105-
election_slug: str,
106-
position: str
107-
):
108-
await db_session.execute(
109-
sqlalchemy
110-
.delete(NomineeApplication)
111-
.where(
112-
(NomineeApplication.computing_id == computing_id)
113-
& (NomineeApplication.nominee_election == election_slug)
114-
& (NomineeApplication.position == position)
115-
)
116-
)
117-
118-
# ------------------------------------------------------- #
119-
120-
async def get_nominee_info(
121-
db_session: AsyncSession,
122-
computing_id: str,
123-
) -> NomineeInfo | None:
124-
return await db_session.scalar(
125-
sqlalchemy
126-
.select(NomineeInfo)
127-
.where(NomineeInfo.computing_id == computing_id)
128-
)
129-
130-
async def create_nominee_info(
131-
db_session: AsyncSession,
132-
info: NomineeInfo,
133-
):
134-
db_session.add(info)
135-
136-
async def update_nominee_info(
137-
db_session: AsyncSession,
138-
info: NomineeInfo,
139-
):
140-
await db_session.execute(
141-
sqlalchemy
142-
.update(NomineeInfo)
143-
.where(NomineeInfo.computing_id == info.computing_id)
144-
.values(info.to_update_dict())
145-
)

src/elections/models.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,49 @@
11
from enum import StrEnum
22

3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, Field
4+
5+
from officers.constants import OfficerPositionEnum
6+
from registrations.models import RegistrationModel
47

58

69
class ElectionTypeEnum(StrEnum):
710
GENERAL = "general_election"
811
BY_ELECTION = "by_election"
912
COUNCIL_REP = "council_rep_election"
1013

11-
class ElectionModel(BaseModel):
14+
class ElectionStatusEnum(StrEnum):
15+
BEFORE_NOMINATIONS = "before_nominations"
16+
NOMINATIONS = "nominations"
17+
VOTING = "voting"
18+
AFTER_VOTING = "after_voting"
19+
20+
class ElectionResponse(BaseModel):
1221
slug: str
1322
name: str
1423
type: ElectionTypeEnum
1524
datetime_start_nominations: str
1625
datetime_start_voting: str
1726
datetime_end_voting: str
18-
available_positions: str
27+
available_positions: list[OfficerPositionEnum]
28+
status: ElectionStatusEnum
29+
30+
survey_link: str | None = Field(None, description="Only available to admins")
31+
candidates: list[RegistrationModel] | None = Field(None, description="Only available to admins")
32+
33+
class ElectionParams(BaseModel):
34+
name: str
35+
type: ElectionTypeEnum
36+
datetime_start_nominations: str
37+
datetime_start_voting: str
38+
datetime_end_voting: str
39+
available_positions: list[OfficerPositionEnum] | None = None
40+
survey_link: str | None = None
41+
42+
class ElectionUpdateParams(BaseModel):
43+
type: ElectionTypeEnum | None = None
44+
datetime_start_nominations: str | None = None
45+
datetime_start_voting: str | None = None
46+
datetime_end_voting: str | None = None
47+
available_positions: list[OfficerPositionEnum] | None = None
1948
survey_link: str | None = None
2049

21-
class NomineeInfoModel(BaseModel):
22-
computing_id: str
23-
full_name: str
24-
linked_in: str
25-
instagram: str
26-
email: str
27-
discord_username: str
28-
29-
class NomineeApplicationModel(BaseModel):
30-
computing_id: str
31-
nominee_election: str
32-
position: str
33-
speech: str

src/elections/tables.py

Lines changed: 39 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,19 @@
11
from datetime import datetime
22

33
from sqlalchemy import (
4-
Column,
54
DateTime,
6-
ForeignKey,
7-
PrimaryKeyConstraint,
85
String,
9-
Text,
106
)
7+
from sqlalchemy.orm import Mapped, mapped_column
118

12-
from constants import (
13-
COMPUTING_ID_LEN,
14-
DISCORD_ID_LEN,
15-
DISCORD_NAME_LEN,
16-
DISCORD_NICKNAME_LEN,
17-
)
189
from database import Base
19-
from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS
20-
21-
# If you wish to add more elections & defaults, please see `create_election`
22-
election_types = ["general_election", "by_election", "council_rep_election"]
23-
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"
10+
from elections.models import (
11+
ElectionStatusEnum,
12+
ElectionTypeEnum,
13+
ElectionUpdateParams,
14+
)
15+
from officers.constants import OfficerPositionEnum
16+
from utils.types import StringList
3217

3318
MAX_ELECTION_NAME = 64
3419
MAX_ELECTION_SLUG = 64
@@ -37,16 +22,19 @@ class Election(Base):
3722
__tablename__ = "election"
3823

3924
# Slugs are unique identifiers
40-
slug = Column(String(MAX_ELECTION_SLUG), primary_key=True)
41-
name = Column(String(MAX_ELECTION_NAME), nullable=False)
42-
type = Column(String(64), default="general_election")
43-
datetime_start_nominations = Column(DateTime, nullable=False)
44-
datetime_start_voting = Column(DateTime, nullable=False)
45-
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)
49-
survey_link = Column(String(300))
25+
slug: Mapped[str] = mapped_column(String(MAX_ELECTION_SLUG), primary_key=True)
26+
name: Mapped[str] = mapped_column(String(MAX_ELECTION_NAME), nullable=False)
27+
type: Mapped[ElectionTypeEnum] = mapped_column(String(64), default=ElectionTypeEnum.GENERAL)
28+
datetime_start_nominations: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
29+
datetime_start_voting: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
30+
datetime_end_voting: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
31+
32+
# a comma-separated string of positions which must be elements of OfficerPosition
33+
# By giving it the type `StringList`, the database entry will automatically be marshalled to the correct form
34+
# DB -> Python: str -> list[str]
35+
# Python -> DB: list[str] -> str
36+
available_positions: Mapped[list[OfficerPositionEnum]] = mapped_column(StringList(), nullable=False,)
37+
survey_link: Mapped[str | None] = mapped_column(String(300))
5038

5139
def private_details(self, at_time: datetime) -> dict:
5240
# is serializable
@@ -107,81 +95,27 @@ def to_update_dict(self) -> dict:
10795
"survey_link": self.survey_link,
10896
}
10997

98+
def update_from_params(self, params: ElectionUpdateParams):
99+
update_data = params.model_dump(
100+
exclude_unset=True,
101+
exclude={"datetime_start_nominations", "datetime_start_voting", "datetime_end_voting"}
102+
)
103+
for k, v in update_data.items():
104+
setattr(self, k, v)
105+
if params.datetime_start_nominations:
106+
self.datetime_start_nominations = datetime.fromisoformat(params.datetime_start_nominations)
107+
if params.datetime_start_voting:
108+
self.datetime_start_voting = datetime.fromisoformat(params.datetime_start_voting)
109+
if params.datetime_end_voting:
110+
self.datetime_end_voting = datetime.fromisoformat(params.datetime_end_voting)
111+
110112
def status(self, at_time: datetime) -> str:
111113
if at_time <= self.datetime_start_nominations:
112-
return STATUS_BEFORE_NOMINATIONS
114+
return ElectionStatusEnum.BEFORE_NOMINATIONS
113115
elif at_time <= self.datetime_start_voting:
114-
return STATUS_NOMINATIONS
116+
return ElectionStatusEnum.NOMINATIONS
115117
elif at_time <= self.datetime_end_voting:
116-
return STATUS_VOTING
118+
return ElectionStatusEnum.VOTING
117119
else:
118-
return STATUS_AFTER_VOTING
119-
120-
class NomineeInfo(Base):
121-
__tablename__ = "election_nominee_info"
122-
123-
computing_id = Column(String(COMPUTING_ID_LEN), primary_key=True)
124-
full_name = Column(String(64), nullable=False)
125-
linked_in = Column(String(128))
126-
instagram = Column(String(128))
127-
email = Column(String(64))
128-
discord_username = Column(String(DISCORD_NICKNAME_LEN))
129-
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-
156-
class NomineeApplication(Base):
157-
__tablename__ = "election_nominee_application"
158-
159-
# TODO: add index for nominee_election?
160-
computing_id = Column(ForeignKey("election_nominee_info.computing_id"), primary_key=True)
161-
nominee_election = Column(ForeignKey("election.slug"), primary_key=True)
162-
position = Column(String(64), primary_key=True)
163-
164-
speech = Column(Text)
165-
166-
__table_args__ = (
167-
PrimaryKeyConstraint(computing_id, nominee_election, position),
168-
)
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-
}
120+
return ElectionStatusEnum.AFTER_VOTING
187121

0 commit comments

Comments
 (0)