Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ repos:
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version
rev: v0.15.12
rev: v0.16.4
hooks:
# Run linter
- id: ruff-check
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ line-ending = "lf"
select = ["E", "F", "B", "I", "N", "UP", "A", "PTH", "W", "RUF", "C4", "PIE", "Q", "FLY"] # "ANN"
ignore = ["E501", "F401", "N806"]

# TODO: Re-enable once F401 is introduced globally
# [tool.ruff.lint.per-file-ignores]
# "src/load_test_db.py" = ["F401"]

# [Based]Pyright: Type checker/LSP
[tool.pyright]
executionEnvironments = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""update event table to have groups, location, organizer and status

Revision ID: b793b71fff3e
Revises: 81f44578da6d
Create Date: 2026-08-28 17:51:07.556757

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = 'b793b71fff3e'
down_revision: Union[str, None] = '81f44578da6d'
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.add_column('event_info', sa.Column('start_datetime', sa.DateTime(timezone=True), nullable=False))
op.add_column('event_info', sa.Column('end_datetime', sa.DateTime(timezone=True), nullable=False))
op.add_column('event_info', sa.Column('group_id', sa.Uuid(), nullable=True))
op.add_column('event_info', sa.Column('location', sa.Text(), nullable=True))
op.add_column('event_info', sa.Column('organizer', sa.Text(), nullable=True))
op.add_column('event_info', sa.Column('status', sa.Enum('cancelled', 'scheduled', name='valid_status', native_enum=False, create_constraint=True), nullable=False))
op.add_column('event_info', sa.Column('url', sa.Text(), nullable=True))
op.add_column('event_info', sa.Column('image_id', sa.Integer(), nullable=True))
op.alter_column('event_info', 'description',
existing_type=sa.TEXT(),
nullable=False)
op.alter_column('event_info', 'name',
existing_type=sa.VARCHAR(length=64),
type_=sa.Text(),
existing_nullable=False)
op.create_index(op.f('ix_event_info_group_id'), 'event_info', ['group_id'], unique=False)
op.create_foreign_key(op.f('fk_event_info_image_id_image_asset'), 'event_info', 'image_asset', ['image_id'], ['image_id'])
op.drop_constraint(op.f('ck_event_info_valid_frequency_value'), 'event_info', type_='check')
op.drop_constraint(op.f('ck_event_info_check_repeat_start_date_before_repeat_end_date'), 'event_info', type_='check')
op.drop_constraint(op.f('ck_event_info_check_start_time_before_end_time'), 'event_info', type_='check')
op.create_check_constraint(op.f('ck_event_info_check_start_datetime_before_end_datetime'), 'event_info', 'start_datetime <= end_datetime')
op.drop_column('event_info', 'end_time')
op.drop_column('event_info', 'frequency')
op.drop_column('event_info', 'repeat_end_date')
op.drop_column('event_info', 'repeat_start_date')
op.drop_column('event_info', 'start_time')
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('event_info', sa.Column('start_time', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False))
op.add_column('event_info', sa.Column('repeat_start_date', sa.DATE(), autoincrement=False, nullable=True))
op.add_column('event_info', sa.Column('repeat_end_date', sa.DATE(), autoincrement=False, nullable=True))
op.add_column('event_info', sa.Column('frequency', sa.VARCHAR(length=64), server_default=sa.text("'NONE'::character varying"), autoincrement=False, nullable=False))
op.add_column('event_info', sa.Column('end_time', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False))
op.drop_constraint(op.f('ck_event_info_check_start_datetime_before_end_datetime'), 'event_info', type_='check')
op.create_check_constraint(op.f('ck_event_info_check_start_time_before_end_time'), 'event_info', 'start_time < end_time')
op.create_check_constraint(op.f('ck_event_info_valid_frequency_value'), 'event_info', "frequency IN ('NONE', 'DAILY', 'WEEKLY', 'MONTHLY', 'SEMESTERLY', 'YEARLY')")
op.create_check_constraint(op.f('ck_event_info_check_repeat_start_date_before_repeat_end_date'), 'event_info', 'repeat_start_date < repeat_end_date')
op.drop_constraint(op.f('fk_event_info_image_id_image_asset'), 'event_info', type_='foreignkey')
op.drop_index(op.f('ix_event_info_group_id'), table_name='event_info')
op.alter_column('event_info', 'name',
existing_type=sa.Text(),
type_=sa.VARCHAR(length=64),
existing_nullable=False)
op.alter_column('event_info', 'description',
existing_type=sa.TEXT(),
nullable=True)
op.drop_column('event_info', 'image_id')
op.drop_column('event_info', 'url')
op.drop_column('event_info', 'status')
op.drop_column('event_info', 'organizer')
op.drop_column('event_info', 'location')
op.drop_column('event_info', 'group_id')
op.drop_column('event_info', 'end_datetime')
op.drop_column('event_info', 'start_datetime')
# ### end Alembic commands ###
1 change: 0 additions & 1 deletion src/database.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import asyncio
import contextlib
import os
from collections.abc import AsyncGenerator
from typing import Annotated, Any

Expand Down
10 changes: 8 additions & 2 deletions src/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
from typing import Annotated

from fastapi import Cookie, Depends, HTTPException, status
from fastapi import Cookie, Depends, HTTPException, Path, status

import auth
import auth.crud
import database
from auth.constants import COOKIE_SESSION_KEY
from config import settings
from utils.permissions import is_user_election_admin, is_user_website_admin

# Dependency to ensure years in paths are valid
# Honestly don't know if this DB will be running past year 3000
YearPath = Annotated[int, Path(ge=2000, le=3000)]

# Dependency to ensure months in paths are valid
MonthPath = Annotated[int, Path(ge=1, le=12)]


async def optional_user(
db_session: database.DBSession, session_id: Annotated[str | None, Cookie(alias=COOKIE_SESSION_KEY)] = None
Expand Down
10 changes: 3 additions & 7 deletions src/event/constants.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
from enum import StrEnum


class EventFrequencyEnum(StrEnum):
NONE = "NONE"
DAILY = "DAILY"
WEEKLY = "WEEKLY"
MONTHLY = "MONTHLY"
SEMESTERLY = "SEMESTERLY"
YEARLY = "YEARLY"
class EventStatusEnum(StrEnum):
CANCELLED = "cancelled"
SCHEDULED = "scheduled"
101 changes: 68 additions & 33 deletions src/event/crud.py
Original file line number Diff line number Diff line change
@@ -1,58 +1,93 @@
from collections.abc import Sequence
from datetime import date, datetime
from datetime import datetime
from uuid import UUID

from sqlalchemy import and_, delete, extract, or_, select
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession

from constants import TZ_INFO
from event.constants import EventStatusEnum
from event.tables import EventDB


async def get_all_events(db_session: AsyncSession) -> Sequence[EventDB]:
events = (await db_session.scalars(select(EventDB))).all()
return events
async def get_all_events(db_session: AsyncSession, include_cancelled: bool) -> Sequence[EventDB]:
"""
Get all non-cancelled events, by default.

Args:
db_session: database session

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
Returns:
A list of events, ordered by descending start_datetime and end_datetime.
"""
query = select(EventDB) if include_cancelled else select(EventDB).where(EventDB.status != EventStatusEnum.CANCELLED)
return (await db_session.scalars(query.order_by(EventDB.start_datetime.desc(), EventDB.end_datetime.desc()))).all()


async def get_events_for_this_year_month(
db_session: AsyncSession,
year: int,
month: int,
include_cancelled: bool,
) -> 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()
"""
Gets all events that occur during the month, including ones that start before the month and year,
and end after the month and year, assuming America/Vancouver timezone.

Args:
db_session: database session
year: the year to check
month: the month to check

Returns:
A list of events that overlap the month and year, ordered by ascending start_datetime and end_datetime.
"""
period_start = datetime(year, month, 1, tzinfo=TZ_INFO)

if month == 12:
period_end = datetime(year + 1, 1, 1, tzinfo=TZ_INFO)
else:
period_end = datetime(year, month + 1, 1, tzinfo=TZ_INFO)
query = (
select(EventDB)
.where(EventDB.start_datetime < period_end, EventDB.end_datetime >= period_start)
.order_by(EventDB.start_datetime, EventDB.end_datetime)
)

if not include_cancelled:
query = query.where(EventDB.status != EventStatusEnum.CANCELLED)

events = (await db_session.scalars(query)).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()
return await db_session.get(EventDB, eid)


async def get_events_by_group_id(db_session: AsyncSession, group_id: UUID) -> Sequence[EventDB]:
query = select(EventDB).where(EventDB.group_id == group_id).order_by(EventDB.start_datetime, EventDB.end_datetime)

result = await db_session.execute(query)

return result.scalars().all()


async def create_event(db_session: AsyncSession, info: EventDB):
def create_event(db_session: AsyncSession, info: EventDB) -> None:
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
def create_bulk_event(db_session: AsyncSession, events: list[EventDB]) -> None:
db_session.add_all(events)


async def delete_event(db_session: AsyncSession, eid: int) -> int | None:
query = delete(EventDB).where(EventDB.eid == eid).returning(EventDB.eid)
return await db_session.scalar(query)


async def delete_group_events(db_session: AsyncSession, group_id: UUID) -> Sequence[int]:
query = delete(EventDB).where(EventDB.group_id == group_id).returning(EventDB.eid)

return (await db_session.scalars(query)).all()
68 changes: 44 additions & 24 deletions src/event/models.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,74 @@
import datetime
from collections.abc import Sequence
from uuid import UUID

from pydantic import BaseModel, ConfigDict, model_validator
from pydantic import AwareDatetime, BaseModel, ConfigDict, model_validator

from event.constants import EventFrequencyEnum
from event.constants import EventStatusEnum


class BaseEvent(BaseModel):
name: str
start_time: datetime.datetime
end_time: datetime.datetime
description: str | None = None
frequency: EventFrequencyEnum | None = None
repeat_start_date: datetime.date | None = None
repeat_end_date: datetime.date | None = None
description: str
start_datetime: AwareDatetime
end_datetime: AwareDatetime
location: str | None = None
organizer: str | None = None
status: EventStatusEnum
url: str | None = None
Comment thread
arunPdl02 marked this conversation as resolved.
image_id: int | None = None

@model_validator(mode="after")
def validate_time_range(self) -> "BaseEvent":
if self.start_time >= self.end_time:
raise ValueError("The event start must be before the event end")

if self.repeat_start_date and self.repeat_end_date:
if self.repeat_start_date > self.repeat_end_date:
raise ValueError("The event repeat start date must be before the end date")

if (self.repeat_start_date is None) != (self.repeat_end_date is None):
raise ValueError("The event must have both repeat start and repeat end or have neither.")

if self.start_datetime > self.end_datetime:
raise ValueError("Event start times cannot be greater than end times")
return self


class Event(BaseEvent):
model_config = ConfigDict(from_attributes=True)

eid: int
group_id: UUID | None = None


class EventCreate(BaseEvent):
pass


class GroupEvent(BaseModel):
group_id: UUID
events: list[Event]


class EventUpdate(BaseModel):
"""
Partial patch payload for PATCH-style updates. Deliberately does NOT
inherit from BaseEvent. Inherting from BaseEvent would also inherit the validation
which will caude bugs for None values, to avoid that we would need a db call. Hence,
the validation is done in the routing layer. Every field is optional here since a client
only sends the fields they want to change.

Group IDs cannot be modified once created, you have to create a new group of events.
"""

model_config = ConfigDict(extra="forbid")
name: str | None = None
start_time: datetime.datetime | None = None
end_time: datetime.datetime | None = None
description: str | None = None
frequency: EventFrequencyEnum | None = None
repeat_start_date: datetime.date | None = None
repeat_end_date: datetime.date | None = None
start_datetime: AwareDatetime | None = None
end_datetime: AwareDatetime | None = None
location: str | None = None
organizer: str | None = None
status: EventStatusEnum | None = None
url: str | None = None
image_id: int | None = None


class EventDelete(BaseModel):
result: bool
eid: int


class GroupEventDeleteResponse(BaseModel):
result: bool
group_id: UUID
deleted_eids: Sequence[int]
Loading
Loading