diff --git a/src/alembic/env.py b/src/alembic/env.py index 7605c32..3693ae0 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -12,6 +12,7 @@ import nominees.tables import officers.tables import candidates.tables +import event.tables from alembic import context # this is the Alembic Config object, which provides diff --git a/src/alembic/versions/f4c493a24799_create_event_table.py b/src/alembic/versions/f4c493a24799_create_event_table.py new file mode 100644 index 0000000..50c0ae1 --- /dev/null +++ b/src/alembic/versions/f4c493a24799_create_event_table.py @@ -0,0 +1,42 @@ +"""create_event_table + +Revision ID: f4c493a24799 +Revises: 0a2c458d1ddd +Create Date: 2026-05-15 23:00:45.680647 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f4c493a24799' +down_revision: Union[str, None] = '0a2c458d1ddd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('event_info', + sa.Column('eid', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('start_time', sa.DateTime(timezone=True), nullable=False), + sa.Column('end_time', sa.DateTime(timezone=True), nullable=False), + sa.Column('repeat', sa.String(length=64), nullable=False), + sa.Column('start_date', sa.Date(), nullable=True), + sa.Column('end_date', sa.Date(), nullable=True), + sa.CheckConstraint('start_date < end_date', name=op.f('ck_event_info_check_start_date_before_end_date')), + sa.CheckConstraint('start_time < end_time', name=op.f('ck_event_info_check_start_time_before_end_time')), + sa.PrimaryKeyConstraint('eid', name=op.f('pk_event_info')) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('event_info') + # ### end Alembic commands ### diff --git a/src/event/crud.py b/src/event/crud.py new file mode 100644 index 0000000..323ddd2 --- /dev/null +++ b/src/event/crud.py @@ -0,0 +1,79 @@ +from collections.abc import Sequence + +from sqlalchemy import select, or_, and_, extract, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from event.tables import EventDB + +from datetime import datetime, date + + +async def get_all_events( + db_session: AsyncSession +) -> Sequence[EventDB]: + events = (await db_session.scalars(select(EventDB))).all() + return events + + +async def get_events_for_this_year( + db_session: AsyncSession, + year: int, +) -> Sequence[EventDB]: + events = (await db_session.scalars(select(EventDB).where + ( + or_( + extract('year', EventDB.start_time) == year, + extract('year', EventDB.end_time) == year + ) + ))).all() + return events + +async def get_events_for_this_year_month( + db_session: AsyncSession, + year: int, + month: int, +) -> Sequence[EventDB]: + events = ( + await db_session.scalars( + select(EventDB).where( + or_( + and_( + extract('year', EventDB.start_time) == year, + extract('month', EventDB.start_time) == month + ), + and_( + extract('year', EventDB.end_time) == year, + extract('month', EventDB.end_time) == month + ) + ) + ) + ) + ).all() + return events + + +async def get_event_by_eid( + db_session: AsyncSession, + eid: int +) -> EventDB | None: + return (await db_session.execute( + select(EventDB).where(EventDB.eid == eid) + )).scalar_one_or_none() + + +async def create_event( + db_session: AsyncSession, + info: EventDB +): + db_session.add(info) + + +async def delete_event( + db_session: AsyncSession, + eid: int +): + result = await db_session.execute(delete(EventDB).where( + EventDB.eid == eid + )) + # Return the number of rows affected + return result.rowcount \ No newline at end of file diff --git a/src/event/models.py b/src/event/models.py new file mode 100644 index 0000000..f3794e1 --- /dev/null +++ b/src/event/models.py @@ -0,0 +1,69 @@ +from pydantic import BaseModel, ConfigDict, model_validator +import datetime + +class Event(BaseModel): + model_config = ConfigDict(from_attributes=True) + eid: int + name: str + start_time: datetime.datetime + end_time: datetime.datetime + description: str | None = None + repeat: str | None = None + start_date: datetime.date | None = None + end_date: datetime.date | None = None + +class EventPublic(BaseModel): + model_config = ConfigDict(from_attributes=True) + name: str + start_time: datetime.datetime + end_time: datetime.datetime + description: str | None = None + repeat: str | None = None + start_date: datetime.date | None = None + end_date: datetime.date | None = None + +class EventCreate(BaseModel): + name: str + start_time: datetime.datetime + end_time: datetime.datetime + description: str | None = None + repeat: str | None = None + start_date: datetime.date | None = None + end_date: datetime.date | None = None + + @model_validator(mode="after") + def validate_time_range(self) -> "EventCreate": + if self.start_time >= self.end_time: + raise ValueError("The event start must be before the event end") + + if self.start_date and self.end_date: + if self.start_date > self.end_date: + raise ValueError("The event repeat start date must be before the end date") + + if self.start_date and not self.end_date: + raise ValueError("The event can't have start date but not end date") + + if not self.start_date and self.end_date: + raise ValueError("The event can't have end date but not start date") + + return self + +class EventUpdate(BaseModel): + name: str | None = None + start_time: datetime.datetime | None = None + end_time: datetime.datetime | None = None + description: str | None = None + repeat: str | None = None + start_date: datetime.date | None = None + end_date: datetime.date | None = None + + @model_validator(mode="after") + def validate_time_range(self) -> "EventUpdate": + if self.start_time and self.end_time: + if self.start_time > self.end_time: + raise ValueError("The event start time must be before end time") + return self + +class EventDelete(BaseModel): + result: bool + eid: int \ No newline at end of file diff --git a/src/event/tables.py b/src/event/tables.py new file mode 100644 index 0000000..cf770b6 --- /dev/null +++ b/src/event/tables.py @@ -0,0 +1,70 @@ +from sqlalchemy import ( + Integer, + String, + DateTime, + Text, + Date, + CheckConstraint +) +from sqlalchemy.orm import Mapped, mapped_column + +from database import Base +from datetime import datetime, date + +class EventDB(Base): + __tablename__ = "event_info" + + eid: Mapped[int] = mapped_column( + Integer, + primary_key=True, + autoincrement=True + ) + description: Mapped[str] = mapped_column( + Text, + nullable=True + ) + name: Mapped[str] = mapped_column( + String(64) + ) + start_time: Mapped[datetime] = mapped_column( + DateTime(timezone=True) + ) + end_time: Mapped[datetime] = mapped_column( + DateTime(timezone=True) + ) + repeat: Mapped[str] = mapped_column( + String(64) + ) + start_date: Mapped[date] = mapped_column( + Date, + nullable=True + ) + end_date: Mapped[date] = mapped_column( + Date, + nullable=True + ) + + __table_args__ = ( + CheckConstraint( + 'start_time < end_time', + name='check_start_time_before_end_time' + ), + CheckConstraint( + 'start_date < end_date', + name='check_start_date_before_end_date' + ) + ) + + + def serialize(self) -> dict: + return{ + "eid": self.eid, + "name": self.name, + "description": self.description, + "start_time": self.start_time, + "end_time": self.end_time, + "start_date": self.start_date, + "end_date": self.end_date, + } + + \ No newline at end of file diff --git a/src/event/urls.py b/src/event/urls.py new file mode 100644 index 0000000..e708c33 --- /dev/null +++ b/src/event/urls.py @@ -0,0 +1,186 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import JSONResponse + +import database +import event.crud +from event.models import ( + Event, + EventPublic, + EventCreate, + EventUpdate, + EventDelete +) +from event.tables import EventDB +from utils.shared_models import DetailModel, SuccessResponse +from datetime import datetime, date + +router = APIRouter( + prefix="/event", + tags=["event"], +) + +@router.get( + "", + description="Get all events", + response_model=list[EventPublic], + # responses={}, + operation_id="get_all_events", + # probably want it to be public so no dependecies? + # dependecies=[Depends()] +) +async def get_all_events( + db_session: database.DBSession, +): + events_list = await event.crud.get_all_events(db_session) + + return events_list + + +@router.get( + "/{year}", + description="Get events that start OR end in this year", + response_model=list[EventPublic], + # responses= {} + operation_id="get_events_for_this_year" +) +async def get_events_for_this_year( + db_session: database.DBSession, + year: int, +): + events_list = await event.crud.get_events_for_this_year(db_session, year) + + return events_list + + +@router.get( + "/{year}/{month}", + description="Get events that start OR end in the given year and month", + response_model=list[EventPublic], + # responses= {} + operation_id="get_events_for_this_year_month" +) +async def get_events_for_this_year_month( + db_session: database.DBSession, + year: int, + month: int +): + events_list = await event.crud.get_events_for_this_year_month(db_session, year, month) + + return events_list + + +@router.post( + "", + description="Create a new event", + response_model=Event, + status_code=status.HTTP_201_CREATED, + responses={ + 500: {"description": "failed to fetch new event", "model": DetailModel}, + }, + operation_id="create_event", + # dependecies=[Depends()] +) +async def create_event( + db_session: database.DBSession, + body: EventCreate +): + new_event = EventDB(**body.model_dump()) + await event.crud.create_event( + db_session, + new_event, + ) + + await db_session.commit() + await db_session.refresh(new_event) + + return new_event + + +@router.patch( + "/{eid}", + description="Update an Event detail", + response_model=Event, + responses={ + 404:{"description": "Event doesn't exist."} + }, + operation_id="update_event" +) +async def update_event( + db_session: database.DBSession, + eid: int, + body: EventUpdate +): + db_event = await event.crud.get_event_by_eid(db_session, eid) + if db_event is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Event doesn't exist." + ) + + final_start_time = body.start_time if body.start_time is not None else db_event.start_time + final_end_time = body.end_time if body.end_time is not None else db_event.end_time + + if final_start_time > final_end_time: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="The event start time must be before the end time" + ) + + if not body.start_date and body.end_date: + if not db_event.start_date: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="The event start date and event end date must be initilized at the same time" + ) + if db_event.start_date > body.end_date: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="The event start date must be before the event end date" + ) + if body.start_date and not body.end_date: + if not db_event.end_date: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="The event start date and event end date must be initilized at the same time" + ) + if body.start_date > db_event.end_date: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="The event start date must be before the event end date" + ) + + updated_data = body.model_dump(exclude_unset=True) + for key, value in updated_data.items(): + setattr(db_event, key, value) + + await db_session.commit() + await db_session.refresh(db_event) + + return db_event + + + +@router.delete( + "/{eid}", + description="Delete an event", + response_model=EventDelete, + responses={ + 404:{"description": "Event doesn't exist."} + }, + operation_id="delete_event", + # dependecies=[Depends()], +) +async def delete_event( + db_session: database.DBSession, + eid: int +): + rows_deleted = await event.crud.delete_event(db_session, eid) + + if rows_deleted == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Event doesn't exist." + ) + + await db_session.commit() + return EventDelete(result=True, eid=eid) \ No newline at end of file diff --git a/src/main.py b/src/main.py index 66734c0..720296f 100755 --- a/src/main.py +++ b/src/main.py @@ -13,6 +13,7 @@ import nominees.urls import officers.urls import permission.urls +import event.urls from constants import IS_PROD logging.basicConfig(level=logging.DEBUG) @@ -58,6 +59,7 @@ app.include_router(nominees.urls.router) app.include_router(officers.urls.router) app.include_router(permission.urls.router) +app.include_router(event.urls.router) @app.get("/")