Skip to content
Open
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
1 change: 1 addition & 0 deletions src/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import database
import elections.tables
import event.tables
import event_check_in.tables # Register the check-in table for Alembic autogeneration.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the comment

import honorary.tables
import image_asset.tables
import nominees.tables
Expand Down
51 changes: 51 additions & 0 deletions src/alembic/versions/2441c037abe2_add_event_check_in_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""add event check-in table

Revision ID: 2441c037abe2
Revises: 00b56b264c3c
Create Date: 2026-10-24 15:18:42.382537

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "2441c037abe2"
down_revision: str | None = "00b56b264c3c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"event_check_in",
sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
sa.Column("event_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default=sa.text("false"), nullable=False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sa.Column("enabled", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default=sa.false(), nullable=False),

This is to prevent our SQL statement scanner from flagging this as unsafe.

sa.Column("opens_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closes_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
Comment on lines +32 to +33

@jbriones1 jbriones1 Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),

This is to prevent our SQL statement scanner from flagging this as unsafe.

sa.CheckConstraint(
"closes_at IS NULL OR opens_at IS NULL OR closes_at > opens_at",
name=op.f("ck_event_check_in_closes_after_opens"),
),
sa.ForeignKeyConstraint(
["event_id"], ["event_info.id"], name=op.f("fk_event_check_in_event_id_event_info"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_event_check_in")),
)
op.create_index(op.f("ix_event_check_in_event_id"), "event_check_in", ["event_id"], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_event_check_in_event_id"), table_name="event_check_in")
op.drop_table("event_check_in")
# ### end Alembic commands ###
2 changes: 2 additions & 0 deletions src/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import database
import elections.urls
import event.urls
import event_check_in.urls
import honorary.urls
import image_asset.urls
import nominees.urls
Expand All @@ -21,6 +22,7 @@
router.include_router(nominees.urls.router)
router.include_router(officers.urls.router)
router.include_router(event.urls.router)
router.include_router(event_check_in.urls.router)
router.include_router(honorary.urls.router)
router.include_router(image_asset.urls.router)
router.include_router(users.urls.router)
67 changes: 67 additions & 0 deletions src/event_check_in/crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession

from event_check_in.models import EventCheckInCreate
from event_check_in.tables import EventCheckInDB


async def get_check_ins(
db_session: AsyncSession,
event_id: int,
) -> list[EventCheckInDB]:
query = select(EventCheckInDB).where(EventCheckInDB.event_id == event_id).order_by(EventCheckInDB.id)
return list((await db_session.scalars(query)).all())


async def get_check_in(
db_session: AsyncSession,
event_id: int,
check_in_id: int,
*,
for_update: bool = False,
) -> EventCheckInDB | None:
# Check both IDs so a configuration cannot be accessed through another event
query = select(EventCheckInDB).where(
EventCheckInDB.event_id == event_id,
EventCheckInDB.id == check_in_id,
)
# Lock this row during PATCH so another update waits until this transaction finishes
if for_update:
query = query.with_for_update()

return await db_session.scalar(query)


def create_check_in(
db_session: AsyncSession,
event_id: int,
data: EventCheckInCreate,
) -> EventCheckInDB:
check_in = EventCheckInDB(event_id=event_id, **data.model_dump())
# Let the endpoint control when changes are committed or rolled back
db_session.add(check_in)
return check_in


def update_check_in(
check_in: EventCheckInDB,
data: EventCheckInCreate,
) -> None:
for field, value in data.model_dump().items():
setattr(check_in, field, value)


async def delete_check_in(
db_session: AsyncSession,
event_id: int,
check_in_id: int,
) -> int | None:
query = (
delete(EventCheckInDB)
.where(
EventCheckInDB.event_id == event_id,
EventCheckInDB.id == check_in_id,
)
.returning(EventCheckInDB.id)
)
return await db_session.scalar(query)
46 changes: 46 additions & 0 deletions src/event_check_in/models.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have the fields in the models be Field(...) and add descriptions so our documentation is better. See src/users/models.py for examples.

Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import Self

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


class EventCheckInCreate(BaseModel):
model_config = ConfigDict(extra="forbid")

name: str = Field(min_length=1, max_length=64, pattern=r"\S")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Define a constant for the max_length.

enabled: bool = False
opens_at: AwareDatetime | None = None
closes_at: AwareDatetime | None = None

@model_validator(mode="after")
def validate_time_range(self) -> Self:
if self.opens_at is not None and self.closes_at is not None:
if self.closes_at <= self.opens_at:
raise ValueError("closes_at must be later than opens_at")
return self


# IDs and audit timestamps are returned to clients but are not writable inputs
class EventCheckIn(EventCheckInCreate):
model_config = ConfigDict(from_attributes=True)

id: int
created_at: AwareDatetime
updated_at: AwareDatetime


# PATCH keeps omitted fields unchanged; null can clear opens_at or closes_at
class EventCheckInUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")

name: str | None = Field(default=None, min_length=1, max_length=64, pattern=r"\S")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the constant defined for the model for the length of the name.

enabled: bool | None = None
opens_at: AwareDatetime | None = None
closes_at: AwareDatetime | None = None

# Name and enabled can be omitted from PATCH, but must have a value when provided
@field_validator("name", "enabled")
@classmethod
def reject_null_values(cls, value: object) -> object:
if value is None:
raise ValueError("This field cannot be null")
return value
72 changes: 72 additions & 0 deletions src/event_check_in/tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from datetime import datetime

from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Identity,
Integer,
String,
false,
func,
)
from sqlalchemy.orm import Mapped, mapped_column

from database import Base


# Each row defines a checkin period, separate from individual attendance records
class EventCheckInDB(Base):
__tablename__ = "event_check_in"

id: Mapped[int] = mapped_column(
BigInteger,
Identity(always=True),
primary_key=True,
)

event_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("event_info.id", ondelete="CASCADE"),
index=True,
)

name: Mapped[str] = mapped_column(String(64))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the constant defined for the model for the length of the name.


enabled: Mapped[bool] = mapped_column(
Boolean,
default=False,
server_default=false(),
)

opens_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)

closes_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)

created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)

# SQLAlchemy supplies this timestamp when it emits an update for the configuration
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)

# Enforce time ordering in PostgreSQL as well as in request validation
__table_args__ = (
CheckConstraint(
"closes_at IS NULL OR opens_at IS NULL OR closes_at > opens_at",
name="closes_after_opens",
),
)
Loading
Loading