Skip to content

Commit aac1432

Browse files
authored
Feature/event issue #108 (#146)
Created database schema using sqlalchemy with proper constraints. Implement the following APIs: GET(/event) GET(/event/<year>) GET(/event/<year>/<month>) POST (/event) PATCH (/event/<eid>) DELETE (/event/<eid>)
1 parent 3683ffd commit aac1432

8 files changed

Lines changed: 328 additions & 0 deletions

File tree

src/alembic/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import nominees.tables
1313
import officers.tables
1414
import candidates.tables
15+
import event.tables
1516
from alembic import context
1617

1718
# this is the Alembic Config object, which provides
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""create_event_table
2+
3+
Revision ID: 42f855bec532
4+
Revises: 0a2c458d1ddd
5+
Create Date: 2026-06-06 21:58:57.956340
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '42f855bec532'
16+
down_revision: Union[str, None] = '0a2c458d1ddd'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
# ### commands auto generated by Alembic - please adjust! ###
23+
op.create_table('event_info',
24+
sa.Column('eid', sa.Integer(), autoincrement=True, nullable=False),
25+
sa.Column('description', sa.Text(), nullable=True),
26+
sa.Column('name', sa.String(length=64), nullable=False),
27+
sa.Column('start_time', sa.DateTime(timezone=True), nullable=False),
28+
sa.Column('end_time', sa.DateTime(timezone=True), nullable=False),
29+
sa.Column('frequency', sa.String(length=64), server_default=sa.text("'NONE'"), nullable=False),
30+
sa.Column('repeat_start_date', sa.Date(), nullable=True),
31+
sa.Column('repeat_end_date', sa.Date(), nullable=True),
32+
sa.CheckConstraint("frequency IN ('NONE', 'DAILY', 'WEEKLY', 'MONTHLY', 'SEMESTERLY', 'YEARLY')", name=op.f('ck_event_info_valid_frequency_value')),
33+
sa.CheckConstraint('repeat_start_date < repeat_end_date', name=op.f('ck_event_info_check_repeat_start_date_before_repeat_end_date')),
34+
sa.CheckConstraint('start_time < end_time', name=op.f('ck_event_info_check_start_time_before_end_time')),
35+
sa.PrimaryKeyConstraint('eid', name=op.f('pk_event_info'))
36+
)
37+
# ### end Alembic commands ###
38+
39+
40+
def downgrade() -> None:
41+
# ### commands auto generated by Alembic - please adjust! ###
42+
op.drop_table('event_info')
43+
# ### end Alembic commands ###

src/event/constants.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from enum import StrEnum
2+
3+
4+
class EventFrequencyEnum(StrEnum):
5+
NONE = "NONE"
6+
DAILY = "DAILY"
7+
WEEKLY = "WEEKLY"
8+
MONTHLY = "MONTHLY"
9+
SEMESTERLY = "SEMESTERLY"
10+
YEARLY = "YEARLY"

src/event/crud.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from collections.abc import Sequence
2+
from datetime import date, datetime
3+
4+
from sqlalchemy import and_, delete, extract, or_, select
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
7+
from event.tables import EventDB
8+
9+
10+
async def get_all_events(db_session: AsyncSession) -> Sequence[EventDB]:
11+
events = (await db_session.scalars(select(EventDB))).all()
12+
return events
13+
14+
15+
async def get_events_for_this_year(
16+
db_session: AsyncSession,
17+
year: int,
18+
) -> Sequence[EventDB]:
19+
events = (
20+
await db_session.scalars(
21+
select(EventDB).where(
22+
or_(extract("year", EventDB.start_time) == year, extract("year", EventDB.end_time) == year)
23+
)
24+
)
25+
).all()
26+
return events
27+
28+
29+
async def get_events_for_this_year_month(
30+
db_session: AsyncSession,
31+
year: int,
32+
month: int,
33+
) -> Sequence[EventDB]:
34+
events = (
35+
await db_session.scalars(
36+
select(EventDB).where(
37+
or_(
38+
and_(extract("year", EventDB.start_time) == year, extract("month", EventDB.start_time) == month),
39+
and_(extract("year", EventDB.end_time) == year, extract("month", EventDB.end_time) == month),
40+
)
41+
)
42+
)
43+
).all()
44+
return events
45+
46+
47+
async def get_event_by_eid(db_session: AsyncSession, eid: int) -> EventDB | None:
48+
return (await db_session.execute(select(EventDB).where(EventDB.eid == eid))).scalar_one_or_none()
49+
50+
51+
async def create_event(db_session: AsyncSession, info: EventDB):
52+
db_session.add(info)
53+
54+
55+
async def delete_event(db_session: AsyncSession, eid: int):
56+
result = await db_session.execute(delete(EventDB).where(EventDB.eid == eid))
57+
# Return the number of rows affected
58+
return result.rowcount

src/event/models.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import datetime
2+
3+
from pydantic import BaseModel, ConfigDict, model_validator
4+
5+
from event.constants import EventFrequencyEnum
6+
7+
8+
class BaseEvent(BaseModel):
9+
name: str
10+
start_time: datetime.datetime
11+
end_time: datetime.datetime
12+
description: str | None = None
13+
frequency: EventFrequencyEnum | None = None
14+
repeat_start_date: datetime.date | None = None
15+
repeat_end_date: datetime.date | None = None
16+
17+
@model_validator(mode="after")
18+
def validate_time_range(self) -> "BaseEvent":
19+
if self.start_time >= self.end_time:
20+
raise ValueError("The event start must be before the event end")
21+
22+
if self.repeat_start_date and self.repeat_end_date:
23+
if self.repeat_start_date > self.repeat_end_date:
24+
raise ValueError("The event repeat start date must be before the end date")
25+
26+
if (self.repeat_start_date is None) != (self.repeat_end_date is None):
27+
raise ValueError("The event must have both repeat start and repeat end or have neither.")
28+
29+
return self
30+
31+
32+
class Event(BaseEvent):
33+
model_config = ConfigDict(from_attributes=True)
34+
eid: int
35+
36+
37+
class EventCreate(BaseEvent):
38+
pass
39+
40+
41+
class EventUpdate(BaseModel):
42+
model_config = ConfigDict(extra="forbid")
43+
name: str | None = None
44+
start_time: datetime.datetime | None = None
45+
end_time: datetime.datetime | None = None
46+
description: str | None = None
47+
frequency: EventFrequencyEnum | None = None
48+
repeat_start_date: datetime.date | None = None
49+
repeat_end_date: datetime.date | None = None
50+
51+
52+
class EventDelete(BaseModel):
53+
result: bool
54+
eid: int

src/event/tables.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from datetime import date, datetime
2+
3+
from sqlalchemy import CheckConstraint, Date, DateTime, Integer, String, Text, text
4+
from sqlalchemy.orm import Mapped, mapped_column
5+
6+
from database import Base
7+
from event.constants import EventFrequencyEnum
8+
9+
10+
class EventDB(Base):
11+
__tablename__ = "event_info"
12+
13+
eid: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
14+
description: Mapped[str] = mapped_column(Text, nullable=True)
15+
name: Mapped[str] = mapped_column(String(64))
16+
start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True))
17+
end_time: Mapped[datetime] = mapped_column(DateTime(timezone=True))
18+
frequency: Mapped[EventFrequencyEnum] = mapped_column(String(64), server_default=text("'NONE'"))
19+
repeat_start_date: Mapped[date] = mapped_column(Date, nullable=True)
20+
repeat_end_date: Mapped[date] = mapped_column(Date, nullable=True)
21+
22+
__table_args__ = (
23+
CheckConstraint("start_time < end_time", name="check_start_time_before_end_time"),
24+
CheckConstraint("repeat_start_date < repeat_end_date", name="check_repeat_start_date_before_repeat_end_date"),
25+
CheckConstraint(frequency.in_([e.value for e in EventFrequencyEnum]), name="valid_frequency_value")
26+
)

src/event/urls.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from datetime import date, datetime
2+
3+
from fastapi import APIRouter, Depends, HTTPException, status
4+
from fastapi.encoders import jsonable_encoder
5+
from fastapi.responses import JSONResponse
6+
from pydantic import ValidationError
7+
8+
import database
9+
import event.crud
10+
from dependencies import perm_admin
11+
from event.models import Event, EventCreate, EventDelete, EventUpdate
12+
from event.tables import EventDB
13+
from utils.shared_models import DetailModel, SuccessResponse
14+
15+
router = APIRouter(
16+
prefix="/event",
17+
tags=["event"],
18+
)
19+
20+
21+
@router.get(
22+
"",
23+
description="Get all events",
24+
response_model=list[Event],
25+
operation_id="get_all_events",
26+
)
27+
async def get_all_events(
28+
db_session: database.DBSession,
29+
):
30+
events_list = await event.crud.get_all_events(db_session)
31+
32+
return events_list
33+
34+
35+
@router.get(
36+
"/{year}",
37+
description="Get events that start OR end in this year",
38+
response_model=list[Event],
39+
operation_id="get_events_for_this_year",
40+
)
41+
async def get_events_for_this_year(
42+
db_session: database.DBSession,
43+
year: int,
44+
):
45+
events_list = await event.crud.get_events_for_this_year(db_session, year)
46+
47+
return events_list
48+
49+
50+
@router.get(
51+
"/{year}/{month}",
52+
description="Get events that start OR end in the given year and month",
53+
response_model=list[Event],
54+
operation_id="get_events_for_this_year_month",
55+
)
56+
async def get_events_for_this_year_month(db_session: database.DBSession, year: int, month: int):
57+
events_list = await event.crud.get_events_for_this_year_month(db_session, year, month)
58+
59+
return events_list
60+
61+
62+
@router.post(
63+
"",
64+
description="Create a new event",
65+
response_model=Event,
66+
status_code=status.HTTP_201_CREATED,
67+
responses={
68+
500: {"description": "failed to fetch new event", "model": DetailModel},
69+
},
70+
operation_id="create_event",
71+
dependencies=[Depends(perm_admin)],
72+
)
73+
async def create_event(db_session: database.DBSession, body: EventCreate):
74+
new_event = EventDB(**body.model_dump())
75+
await event.crud.create_event(
76+
db_session,
77+
new_event,
78+
)
79+
80+
await db_session.commit()
81+
await db_session.refresh(new_event)
82+
83+
return new_event
84+
85+
86+
@router.patch(
87+
"/{eid}",
88+
description="Update an Event detail",
89+
response_model=Event,
90+
responses={404: {"description": "Event doesn't exist."}},
91+
operation_id="update_event",
92+
dependencies=[Depends(perm_admin)],
93+
)
94+
async def update_event(db_session: database.DBSession, eid: int, body: EventUpdate):
95+
db_event = await event.crud.get_event_by_eid(db_session, eid)
96+
if db_event is None:
97+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event doesn't exist.")
98+
99+
db_data = Event.model_validate(db_event).model_dump()
100+
patch_data = body.model_dump(exclude_unset=True)
101+
102+
merged_data = {**db_data, **patch_data}
103+
try:
104+
Event.model_validate(merged_data)
105+
except ValidationError as e:
106+
raise HTTPException(
107+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=jsonable_encoder(e.errors())
108+
) from e
109+
110+
for key, value in patch_data.items():
111+
setattr(db_event, key, value)
112+
113+
await db_session.commit()
114+
await db_session.refresh(db_event)
115+
116+
return db_event
117+
118+
119+
@router.delete(
120+
"/{eid}",
121+
description="Delete an event",
122+
response_model=EventDelete,
123+
responses={404: {"description": "Event doesn't exist."}},
124+
operation_id="delete_event",
125+
dependencies=[Depends(perm_admin)],
126+
)
127+
async def delete_event(db_session: database.DBSession, eid: int):
128+
rows_deleted = await event.crud.delete_event(db_session, eid)
129+
130+
if rows_deleted == 0:
131+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event doesn't exist.")
132+
133+
await db_session.commit()
134+
return EventDelete(result=True, eid=eid)

src/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import candidates.urls
1111
import database
1212
import elections.urls
13+
import event.urls
1314
import nominees.urls
1415
import officers.urls
1516
import permission.urls
@@ -58,6 +59,7 @@
5859
app.include_router(nominees.urls.router)
5960
app.include_router(officers.urls.router)
6061
app.include_router(permission.urls.router)
62+
app.include_router(event.urls.router)
6163

6264

6365
@app.get("/")

0 commit comments

Comments
 (0)