Skip to content

Commit f2fd377

Browse files
authored
Add honourary member endpoints (#150)
Closes #130 Summary: - Added an honorary_member table and Alembic migration - Added honorary member models, CRUD functions, and routes - Registered the /honorary router - Added admin-only endpoints for listing, creating, updating, and deleting honorary member terms
1 parent 70ae1c1 commit f2fd377

9 files changed

Lines changed: 296 additions & 2 deletions

File tree

src/alembic/env.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77

88
import auth.tables
99
import blog.tables
10+
import candidates.tables
1011
import database
1112
import elections.tables
13+
import event.tables
14+
import honorary.tables
1215
import nominees.tables
1316
import officers.tables
14-
import candidates.tables
15-
import event.tables
1617
import translink.tables
1718
from alembic import context
1819

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""create honorary member table
2+
3+
Revision ID: 3c00f28c26e2
4+
Revises: 42f855bec532
5+
Create Date: 2026-06-29 15:43:07.361827
6+
7+
"""
8+
9+
from collections.abc import Sequence
10+
from typing import Union
11+
12+
import sqlalchemy as sa
13+
14+
from alembic import op
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "3c00f28c26e2"
18+
down_revision: str | None = "f0c99d0db277"
19+
branch_labels: str | Sequence[str] | None = None
20+
depends_on: str | Sequence[str] | None = None
21+
22+
23+
def upgrade() -> None:
24+
# ### commands auto generated by Alembic - please adjust! ###
25+
op.create_table(
26+
"honorary_member",
27+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
28+
sa.Column("name", sa.String(length=128), nullable=False),
29+
sa.Column("start_date", sa.Date(), nullable=False),
30+
sa.Column("end_date", sa.Date(), nullable=True),
31+
sa.PrimaryKeyConstraint("id", name=op.f("pk_honorary_member")),
32+
)
33+
# ### end Alembic commands ###
34+
35+
36+
def downgrade() -> None:
37+
# ### commands auto generated by Alembic - please adjust! ###
38+
op.drop_table("honorary_member")
39+
# ### end Alembic commands ###

src/honorary/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
HONORARY_MEMBER_MAX_LENGTH = 128

src/honorary/crud.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from datetime import date
2+
3+
from sqlalchemy import delete, select
4+
5+
import database
6+
from honorary.tables import HonoraryMemberDB
7+
8+
9+
async def get_all_honorary_members(db_session: database.DBSession) -> list[HonoraryMemberDB]:
10+
query = select(HonoraryMemberDB).order_by(HonoraryMemberDB.start_date.desc())
11+
return list((await db_session.scalars(query)).all())
12+
13+
14+
async def get_current_honorary_members(db_session: database.DBSession) -> list[HonoraryMemberDB]:
15+
today = date.today()
16+
query = (
17+
select(HonoraryMemberDB)
18+
.where(
19+
(HonoraryMemberDB.start_date <= today)
20+
& ((HonoraryMemberDB.end_date >= today) | HonoraryMemberDB.end_date.is_(None))
21+
)
22+
.order_by(HonoraryMemberDB.start_date.desc())
23+
)
24+
return list((await db_session.scalars(query)).all())
25+
26+
27+
async def get_honorary_member_by_id(
28+
db_session: database.DBSession,
29+
term_id: int,
30+
) -> HonoraryMemberDB | None:
31+
return await db_session.scalar(select(HonoraryMemberDB).where(HonoraryMemberDB.id == term_id))
32+
33+
34+
def has_term_ended(honorary_member: HonoraryMemberDB) -> bool:
35+
return honorary_member.end_date is not None and honorary_member.end_date < date.today()
36+
37+
38+
async def create_honorary_members(
39+
db_session: database.DBSession,
40+
honorary_members: list[HonoraryMemberDB],
41+
) -> list[HonoraryMemberDB]:
42+
db_session.add_all(honorary_members)
43+
return honorary_members
44+
45+
46+
async def delete_honorary_member(
47+
db_session: database.DBSession,
48+
honorary_member: HonoraryMemberDB,
49+
) -> None:
50+
await db_session.execute(delete(HonoraryMemberDB).where(HonoraryMemberDB.id == honorary_member.id))

src/honorary/models.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from datetime import date
2+
3+
from pydantic import BaseModel, ConfigDict, Field, computed_field
4+
5+
from honorary.constants import HONORARY_MEMBER_MAX_LENGTH
6+
from utils import is_active_term
7+
8+
9+
class HonoraryMemberCreate(BaseModel):
10+
name: str = Field(..., max_length=HONORARY_MEMBER_MAX_LENGTH)
11+
start_date: date
12+
end_date: date | None = None
13+
14+
15+
class HonoraryMember(HonoraryMemberCreate):
16+
model_config = ConfigDict(from_attributes=True)
17+
18+
id: int
19+
20+
@computed_field
21+
@property
22+
def is_active(self) -> bool:
23+
return is_active_term(start_date=self.start_date, end_date=self.end_date)
24+
25+
26+
class HonoraryMemberUpdate(BaseModel):
27+
name: str | None = Field(None, max_length=HONORARY_MEMBER_MAX_LENGTH)
28+
start_date: date | None = None
29+
end_date: date | None = None

src/honorary/tables.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from datetime import date
2+
3+
from sqlalchemy import Date, Integer, String
4+
from sqlalchemy.orm import Mapped, mapped_column
5+
6+
from database import Base
7+
from honorary.constants import HONORARY_MEMBER_MAX_LENGTH
8+
9+
10+
class HonoraryMemberDB(Base):
11+
__tablename__ = "honorary_member"
12+
13+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
14+
name: Mapped[str] = mapped_column(String(HONORARY_MEMBER_MAX_LENGTH), nullable=False)
15+
start_date: Mapped[date] = mapped_column(Date, nullable=False)
16+
end_date: Mapped[date] = mapped_column(Date, nullable=True)

src/honorary/urls.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
from fastapi import APIRouter, Depends, HTTPException, status
2+
from fastapi.responses import JSONResponse
3+
4+
import database
5+
import honorary.crud
6+
from dependencies import perm_admin
7+
from honorary.models import HonoraryMember, HonoraryMemberCreate, HonoraryMemberUpdate
8+
from honorary.tables import HonoraryMemberDB
9+
from utils.shared_models import DetailModel, SuccessResponse
10+
11+
router = APIRouter(
12+
prefix="/honorary",
13+
tags=["honorary"],
14+
)
15+
16+
17+
@router.get(
18+
"",
19+
description="Get all honorary member terms",
20+
response_model=list[HonoraryMember],
21+
responses={403: {"description": "must be a website admin", "model": DetailModel}},
22+
operation_id="get_all_honorary_members",
23+
dependencies=[Depends(perm_admin)],
24+
)
25+
async def get_all_honorary_members(db_session: database.DBSession):
26+
honorary_members = await honorary.crud.get_all_honorary_members(db_session)
27+
return JSONResponse(
28+
[
29+
HonoraryMember.model_validate(member).model_dump(mode="json", exclude_unset=True)
30+
for member in honorary_members
31+
]
32+
)
33+
34+
35+
@router.get(
36+
"/current",
37+
description="Get all active honorary member terms",
38+
response_model=list[HonoraryMember],
39+
responses={403: {"description": "must be a website admin", "model": DetailModel}},
40+
operation_id="get_current_honorary_members",
41+
dependencies=[Depends(perm_admin)],
42+
)
43+
async def get_current_honorary_members(db_session: database.DBSession):
44+
honorary_members = await honorary.crud.get_current_honorary_members(db_session)
45+
return JSONResponse(
46+
[
47+
HonoraryMember.model_validate(member).model_dump(mode="json", exclude_unset=True)
48+
for member in honorary_members
49+
]
50+
)
51+
52+
53+
@router.post(
54+
"",
55+
description="Create honorary member terms",
56+
response_model=list[HonoraryMember],
57+
responses={
58+
403: {"description": "must be a website admin", "model": DetailModel},
59+
500: {"description": "failed to create honorary member terms", "model": DetailModel},
60+
},
61+
operation_id="create_honorary_members",
62+
dependencies=[Depends(perm_admin)],
63+
)
64+
async def create_honorary_members(
65+
db_session: database.DBSession,
66+
body: list[HonoraryMemberCreate],
67+
):
68+
new_members = [HonoraryMemberDB(**member.model_dump()) for member in body]
69+
created_members = await honorary.crud.create_honorary_members(db_session, new_members)
70+
71+
await db_session.flush()
72+
73+
content = [
74+
HonoraryMember.model_validate(member).model_dump(mode="json", exclude_unset=True) for member in created_members
75+
]
76+
77+
await db_session.commit()
78+
79+
return JSONResponse(content)
80+
81+
82+
@router.patch(
83+
"/{term_id}",
84+
description="Update an honorary member term. Ended terms cannot be updated.",
85+
response_model=HonoraryMember,
86+
responses={
87+
403: {"description": "must be a website admin", "model": DetailModel},
88+
404: {"description": "honorary member term does not exist", "model": DetailModel},
89+
409: {"description": "honorary member term has already ended", "model": DetailModel},
90+
},
91+
operation_id="update_honorary_member",
92+
dependencies=[Depends(perm_admin)],
93+
)
94+
async def update_honorary_member(
95+
db_session: database.DBSession,
96+
term_id: int,
97+
body: HonoraryMemberUpdate,
98+
):
99+
honorary_member = await honorary.crud.get_honorary_member_by_id(db_session, term_id)
100+
if honorary_member is None:
101+
raise HTTPException(
102+
status_code=status.HTTP_404_NOT_FOUND,
103+
detail=f"honorary member term with id={term_id} does not exist",
104+
)
105+
106+
if honorary.crud.has_term_ended(honorary_member):
107+
raise HTTPException(
108+
status_code=status.HTTP_409_CONFLICT,
109+
detail="cannot update a term that has already ended",
110+
)
111+
112+
update_data = body.model_dump(exclude_unset=True)
113+
for key, value in update_data.items():
114+
setattr(honorary_member, key, value)
115+
116+
await db_session.commit()
117+
await db_session.refresh(honorary_member)
118+
119+
return JSONResponse(HonoraryMember.model_validate(honorary_member).model_dump(mode="json", exclude_unset=True))
120+
121+
122+
@router.delete(
123+
"/{term_id}",
124+
description="Delete an honorary member term. Ended terms cannot be deleted.",
125+
response_model=SuccessResponse,
126+
responses={
127+
403: {"description": "must be a website admin", "model": DetailModel},
128+
404: {"description": "honorary member term does not exist", "model": DetailModel},
129+
409: {"description": "honorary member term has already ended", "model": DetailModel},
130+
},
131+
operation_id="delete_honorary_member",
132+
dependencies=[Depends(perm_admin)],
133+
)
134+
async def delete_honorary_member(
135+
db_session: database.DBSession,
136+
term_id: int,
137+
):
138+
honorary_member = await honorary.crud.get_honorary_member_by_id(db_session, term_id)
139+
if honorary_member is None:
140+
raise HTTPException(
141+
status_code=status.HTTP_404_NOT_FOUND,
142+
detail=f"honorary member term with id={term_id} does not exist",
143+
)
144+
145+
if honorary.crud.has_term_ended(honorary_member):
146+
raise HTTPException(
147+
status_code=status.HTTP_409_CONFLICT,
148+
detail="cannot delete a term that has already ended",
149+
)
150+
151+
await honorary.crud.delete_honorary_member(db_session, honorary_member)
152+
await db_session.commit()
153+
154+
return SuccessResponse(success=True)

src/load_test_db.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import sqlalchemy
99
from sqlalchemy.ext.asyncio import AsyncSession
1010

11+
import honorary.tables
12+
1113
# NOTE: make sure you import from a file in your module which (at least) indirectly contains those
1214
# tables, or the current python context will not be able to find them & they won't be loaded
1315
from auth.crud import create_user_session, update_site_user

src/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import database
1515
import elections.urls
1616
import event.urls
17+
import honorary.urls
1718
import nominees.urls
1819
import officers.urls
1920
import permission.urls
@@ -78,6 +79,7 @@ async def lifespan(app: FastAPI):
7879
app.include_router(officers.urls.router)
7980
app.include_router(permission.urls.router)
8081
app.include_router(event.urls.router)
82+
app.include_router(honorary.urls.router)
8183
app.include_router(translink.urls.router)
8284

8385

0 commit comments

Comments
 (0)