From ba7409aee998ee8b7db5e17bd67fe6618def6848 Mon Sep 17 00:00:00 2001 From: Danny Choi Date: Sat, 24 Oct 2026 16:24:50 -0700 Subject: [PATCH 1/3] Add event check-in configuration API --- src/alembic/env.py | 1 + .../2441c037abe2_add_event_check_in_table.py | 44 +++ src/api/urls.py | 2 + src/event_check_in/crud.py | 65 ++++ src/event_check_in/models.py | 43 +++ src/event_check_in/tables.py | 69 +++++ src/event_check_in/urls.py | 149 ++++++++++ tests/integration/test_event_check_in.py | 281 ++++++++++++++++++ tests/unit/test_event_check_in_models.py | 74 +++++ 9 files changed, 728 insertions(+) create mode 100644 src/alembic/versions/2441c037abe2_add_event_check_in_table.py create mode 100644 src/event_check_in/crud.py create mode 100644 src/event_check_in/models.py create mode 100644 src/event_check_in/tables.py create mode 100644 src/event_check_in/urls.py create mode 100644 tests/integration/test_event_check_in.py create mode 100644 tests/unit/test_event_check_in_models.py diff --git a/src/alembic/env.py b/src/alembic/env.py index d3280c0..8d682c5 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -1,3 +1,4 @@ +import event_check_in.tables import asyncio from logging.config import fileConfig diff --git a/src/alembic/versions/2441c037abe2_add_event_check_in_table.py b/src/alembic/versions/2441c037abe2_add_event_check_in_table.py new file mode 100644 index 0000000..6b9d505 --- /dev/null +++ b/src/alembic/versions/2441c037abe2_add_event_check_in_table.py @@ -0,0 +1,44 @@ +"""add event check-in table + +Revision ID: 2441c037abe2 +Revises: 00b56b264c3c +Create Date: 2026-10-24 15:18:42.382537 + +""" +from typing import Sequence + +from alembic import op +import sqlalchemy as sa + + +# 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), + 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), + 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 ### diff --git a/src/api/urls.py b/src/api/urls.py index ad50dc9..4ce967c 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -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 @@ -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) diff --git a/src/event_check_in/crud.py b/src/event_check_in/crud.py new file mode 100644 index 0000000..16aa2a7 --- /dev/null +++ b/src/event_check_in/crud.py @@ -0,0 +1,65 @@ +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: + query = select(EventCheckInDB).where( + EventCheckInDB.event_id == event_id, + EventCheckInDB.id == check_in_id, + ) + + 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()) + 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) diff --git a/src/event_check_in/models.py b/src/event_check_in/models.py new file mode 100644 index 0000000..3fa0599 --- /dev/null +++ b/src/event_check_in/models.py @@ -0,0 +1,43 @@ +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") + 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 + + +class EventCheckIn(EventCheckInCreate): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: AwareDatetime + updated_at: AwareDatetime + + +class EventCheckInUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, max_length=64, pattern=r"\S") + enabled: bool | None = None + opens_at: AwareDatetime | None = None + closes_at: AwareDatetime | None = None + + @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 diff --git a/src/event_check_in/tables.py b/src/event_check_in/tables.py new file mode 100644 index 0000000..1b1de95 --- /dev/null +++ b/src/event_check_in/tables.py @@ -0,0 +1,69 @@ +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 + + +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)) + + 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(), + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + __table_args__ = ( + CheckConstraint( + "closes_at IS NULL OR opens_at IS NULL OR closes_at > opens_at", + name="closes_after_opens", + ), + ) diff --git a/src/event_check_in/urls.py b/src/event_check_in/urls.py new file mode 100644 index 0000000..79f5e01 --- /dev/null +++ b/src/event_check_in/urls.py @@ -0,0 +1,149 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Path, Response, status +from fastapi.encoders import jsonable_encoder +from pydantic import ValidationError +from sqlalchemy.exc import IntegrityError + +import database +import event.crud +import event_check_in.crud +from dependencies import perm_event, perm_exec +from event_check_in.models import EventCheckIn, EventCheckInCreate, EventCheckInUpdate +from event_check_in.tables import EventCheckInDB +from utils.shared_models import DetailModel + +CheckInIdPath = Annotated[int, Path(ge=1, le=9223372036854775807)] + +router = APIRouter( + prefix="/event/{eid}/check-in", + tags=["event check-in"], + responses={ + 401: {"model": DetailModel, "description": "Not authenticated"}, + 403: {"model": DetailModel, "description": "Not authorized"}, + 404: {"model": DetailModel, "description": "Event or check-in configuration not found"}, + }, +) + + +async def _get_event_id(db_session: database.DBSession, eid: str) -> int: + 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 not found.") + return db_event.id + + +async def _save_check_in( + db_session: database.DBSession, + check_in: EventCheckInDB, +) -> EventCheckIn: + try: + await db_session.flush() + await db_session.refresh(check_in) + response = EventCheckIn.model_validate(check_in) + await db_session.commit() + except IntegrityError as error: + await db_session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Unable to save check-in configuration.", + ) from error + return response + + +@router.get( + "", + response_model=list[EventCheckIn], + dependencies=[Depends(perm_exec)], +) +async def list_check_ins(db_session: database.DBSession, eid: str): + event_id = await _get_event_id(db_session, eid) + return await event_check_in.crud.get_check_ins(db_session, event_id) + + +@router.get( + "/{check_in_id}", + response_model=EventCheckIn, + dependencies=[Depends(perm_exec)], +) +async def get_check_in( + db_session: database.DBSession, + eid: str, + check_in_id: CheckInIdPath, +): + event_id = await _get_event_id(db_session, eid) + check_in = await event_check_in.crud.get_check_in(db_session, event_id, check_in_id) + if check_in is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Check-in configuration not found.", + ) + return check_in + + +@router.post( + "", + response_model=EventCheckIn, + status_code=status.HTTP_201_CREATED, + responses={409: {"model": DetailModel, "description": "Database conflict"}}, + dependencies=[Depends(perm_event)], +) +async def create_check_in( + db_session: database.DBSession, + eid: str, + body: EventCheckInCreate, +): + event_id = await _get_event_id(db_session, eid) + check_in = event_check_in.crud.create_check_in(db_session, event_id, body) + return await _save_check_in(db_session, check_in) + + +@router.patch( + "/{check_in_id}", + response_model=EventCheckIn, + responses={409: {"model": DetailModel, "description": "Database conflict"}}, + dependencies=[Depends(perm_event)], +) +async def update_check_in( + db_session: database.DBSession, + eid: str, + check_in_id: CheckInIdPath, + body: EventCheckInUpdate, +): + event_id = await _get_event_id(db_session, eid) + check_in = await event_check_in.crud.get_check_in(db_session, event_id, check_in_id, for_update=True) + if check_in is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Check-in configuration not found.", + ) + + current = EventCheckInCreate.model_validate(check_in, from_attributes=True) + updated_data = current.model_dump() | body.model_dump(exclude_unset=True) + + try: + validated = EventCheckInCreate.model_validate(updated_data) + except ValidationError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=jsonable_encoder(error.errors(include_context=False)), + ) from error + + event_check_in.crud.update_check_in(check_in, validated) + return await _save_check_in(db_session, check_in) + + +@router.delete( + "/{check_in_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(perm_event)], +) +async def delete_check_in( + db_session: database.DBSession, + eid: str, + check_in_id: CheckInIdPath, +): + event_id = await _get_event_id(db_session, eid) + await event_check_in.crud.delete_check_in(db_session, event_id, check_in_id) + await db_session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/tests/integration/test_event_check_in.py b/tests/integration/test_event_check_in.py new file mode 100644 index 0000000..4bbfa78 --- /dev/null +++ b/tests/integration/test_event_check_in.py @@ -0,0 +1,281 @@ +from datetime import UTC, datetime, timedelta + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from auth.constants import COOKIE_SESSION_KEY, UserRole +from auth.crud import create_user_session, get_site_user_by_computing_id +from auth.tables import SiteUserRoleDB +from event.constants import EventStatusEnum +from event.tables import EventDB + +pytestmark = [ + pytest.mark.integration, + pytest.mark.asyncio(loop_scope="session"), +] + + +@pytest_asyncio.fixture(loop_scope="session") +async def event_eid(db_session: AsyncSession): + start = datetime(2030, 1, 1, 17, tzinfo=UTC) + event = EventDB( + name="Check-in test event", + description="Event for check-in integration tests.", + start_datetime=start, + end_datetime=start + timedelta(hours=2), + status=EventStatusEnum.SCHEDULED, + ) + db_session.add(event) + await db_session.flush() + eid = event.eid + await db_session.commit() + return eid + + +@pytest_asyncio.fixture(loop_scope="session") +async def check_in(admin_client: AsyncClient, event_eid: str): + response = await admin_client.post( + f"/api/event/{event_eid}/check-in", + json={ + "name": "Main check-in", + "enabled": True, + "opens_at": "2030-01-01T17:00:00Z", + "closes_at": "2030-01-01T18:00:00Z", + }, + ) + assert response.status_code == 201, response.text + return response.json() + + +async def test_check_in_create_read_update_and_delete( + admin_client: AsyncClient, + event_eid: str, + check_in: dict, +): + collection = f"/api/event/{event_eid}/check-in" + individual = f"{collection}/{check_in['id']}" + + assert check_in["name"] == "Main check-in" + assert check_in["enabled"] is True + assert check_in["id"] > 0 + + response = await admin_client.get(collection) + assert response.status_code == 200 + assert response.json() == [check_in] + + response = await admin_client.get(individual) + assert response.status_code == 200 + assert response.json() == check_in + + response = await admin_client.patch( + individual, + json={"enabled": False}, + ) + assert response.status_code == 200 + updated = response.json() + assert updated["enabled"] is False + for field in ("id", "name", "opens_at", "closes_at", "created_at"): + assert updated[field] == check_in[field] + + response = await admin_client.get(individual) + assert response.status_code == 200 + assert response.json() == updated + + response = await admin_client.delete(individual) + assert response.status_code == 204 + assert response.content == b"" + + response = await admin_client.get(individual) + assert response.status_code == 404 + + response = await admin_client.get(collection) + assert response.status_code == 200 + assert response.json() == [] + + response = await admin_client.delete(individual) + assert response.status_code == 204 + assert response.content == b"" + + +@pytest.mark.parametrize( + "changes", + [ + {"closes_at": "2030-01-01T16:00:00Z"}, + {"opens_at": "2030-01-01T19:00:00Z"}, + ], +) +async def test_invalid_partial_update_preserves_saved_data( + admin_client: AsyncClient, + event_eid: str, + check_in: dict, + changes: dict, +): + url = f"/api/event/{event_eid}/check-in/{check_in['id']}" + + response = await admin_client.patch( + url, + json={"name": "Should not be saved", **changes}, + ) + assert response.status_code == 422 + + response = await admin_client.get(url) + assert response.status_code == 200 + assert response.json() == check_in + + +async def test_update_can_clear_time_limits( + admin_client: AsyncClient, + event_eid: str, + check_in: dict, +): + url = f"/api/event/{event_eid}/check-in/{check_in['id']}" + + response = await admin_client.patch( + url, + json={"opens_at": None, "closes_at": None}, + ) + assert response.status_code == 200 + updated = response.json() + assert updated["opens_at"] is None + assert updated["closes_at"] is None + assert updated["name"] == check_in["name"] + assert updated["enabled"] is True + + response = await admin_client.get(url) + assert response.status_code == 200 + assert response.json() == updated + + +@pytest.mark.parametrize( + ("method", "suffix", "payload"), + [ + ("GET", "", None), + ("GET", "/1", None), + ("POST", "", {"name": "Main check-in"}), + ("PATCH", "/1", {"enabled": False}), + ("DELETE", "/1", None), + ], +) +async def test_missing_event_returns_not_found( + admin_client: AsyncClient, + method: str, + suffix: str, + payload: dict | None, +): + url = f"/api/event/00000000000000000000000000/check-in{suffix}" + + response = await admin_client.request(method, url, json=payload) + + assert response.status_code == 404 + assert response.json() == {"detail": "Event not found."} + + +@pytest.mark.parametrize( + ("role", "expected_statuses"), + [ + (None, (401, 401, 401, 401, 401)), + (UserRole.USER, (403, 403, 403, 403, 403)), + (UserRole.EXEC, (200, 200, 403, 403, 403)), + (UserRole.EVENT, (403, 403, 201, 200, 204)), + (UserRole.ADMIN, (200, 200, 201, 200, 204)), + ], +) +@pytest.mark.parametrize( + ("method", "suffix", "payload", "index"), + [ + ("GET", "", None, 0), + ("GET", "/{check_in_id}", None, 1), + ("POST", "", {"name": "Additional check-in"}, 2), + ("PATCH", "/{check_in_id}", {"enabled": False}, 3), + ("DELETE", "/{check_in_id}", None, 4), + ], +) +async def test_check_in_permissions( + db_session: AsyncSession, + client: AsyncClient, + event_eid: str, + check_in: dict, + role: UserRole | None, + expected_statuses: tuple[int, ...], + method: str, + suffix: str, + payload: dict | None, + index: int, +): + client.cookies.clear() + + if role is not None: + session_id = "check-in-permission-session" + await create_user_session(db_session, session_id, "cintest") + user = await get_site_user_by_computing_id(db_session, "cintest") + assert user is not None + + db_session.add( + SiteUserRoleDB( + uid=user.uid, + role=role, + added_by=user.uid, + ) + ) + await db_session.commit() + client.cookies.set( + COOKIE_SESSION_KEY, + session_id, + domain="test.local", + path="/", + ) + + path = suffix.format(check_in_id=check_in["id"]) + url = f"/api/event/{event_eid}/check-in{path}" + + response = await client.request(method, url, json=payload) + + assert response.status_code == expected_statuses[index], response.text + + +@pytest.mark.parametrize( + ("method", "payload", "expected_status"), + [ + ("GET", None, 404), + ("PATCH", {"name": "Wrong event update"}, 404), + ("DELETE", None, 204), + ], +) +async def test_check_in_cannot_be_accessed_through_another_event( + db_session: AsyncSession, + admin_client: AsyncClient, + event_eid: str, + check_in: dict, + method: str, + payload: dict | None, + expected_status: int, +): + start = datetime(2030, 1, 2, 17, tzinfo=UTC) + other_event = EventDB( + name="Unrelated event", + description="This event does not own the check-in.", + start_datetime=start, + end_datetime=start + timedelta(hours=2), + status=EventStatusEnum.SCHEDULED, + ) + db_session.add(other_event) + await db_session.flush() + other_eid = other_event.eid + await db_session.commit() + + wrong_url = f"/api/event/{other_eid}/check-in/{check_in['id']}" + + response = await admin_client.request(method, wrong_url, json=payload) + + assert response.status_code == expected_status, response.text + + original_url = f"/api/event/{event_eid}/check-in/{check_in['id']}" + response = await admin_client.get(original_url) + assert response.status_code == 200 + assert response.json() == check_in + + response = await admin_client.get(f"/api/event/{other_eid}/check-in") + assert response.status_code == 200 + assert response.json() == [] diff --git a/tests/unit/test_event_check_in_models.py b/tests/unit/test_event_check_in_models.py new file mode 100644 index 0000000..fb2d4f4 --- /dev/null +++ b/tests/unit/test_event_check_in_models.py @@ -0,0 +1,74 @@ +import pytest +from pydantic import ValidationError + +from event_check_in.models import EventCheckInCreate, EventCheckInUpdate + +pytestmark = pytest.mark.unit + + +def test_create_defaults_to_disabled_without_time_limits(): + check_in = EventCheckInCreate(name="Main check-in") + + assert check_in.enabled is False + assert check_in.opens_at is None + assert check_in.closes_at is None + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"name": ""}, + {"name": " "}, + {"name": "x" * 65}, + {"name": "Main", "opens_at": "2026-10-25T17:00:00"}, + {"name": "Main", "closes_at": "2026-10-25T18:00:00"}, + ], +) +def test_create_rejects_invalid_names_and_timezone_missing_dates(payload): + with pytest.raises(ValidationError): + EventCheckInCreate.model_validate(payload) + + +@pytest.mark.parametrize( + "closes_at", + [ + "2026-10-25T17:00:00-07:00", + "2026-10-25T16:00:00-07:00", + "2026-10-25T23:30:00Z", + ], +) +def test_create_rejects_closing_at_or_before_opening(closes_at): + with pytest.raises( + ValidationError, + match="closes_at must be later than opens_at", + ): + EventCheckInCreate.model_validate( + { + "name": "Main check-in", + "opens_at": "2026-10-25T17:00:00-07:00", + "closes_at": closes_at, + } + ) + + +def test_update_distinguishes_omitted_fields_from_false_and_null(): + unchanged = EventCheckInUpdate() + updated = EventCheckInUpdate( + enabled=False, + opens_at=None, + closes_at=None, + ) + + assert unchanged.model_dump(exclude_unset=True) == {} + assert updated.model_dump(exclude_unset=True) == { + "enabled": False, + "opens_at": None, + "closes_at": None, + } + + +@pytest.mark.parametrize("field", ["name", "enabled"]) +def test_update_rejects_null_for_required_fields(field): + with pytest.raises(ValidationError): + EventCheckInUpdate.model_validate({field: None}) From 8142ab358a6edc80e34130a83839740af0307374 Mon Sep 17 00:00:00 2001 From: Danny Choi Date: Sun, 25 Oct 2026 22:25:21 -0700 Subject: [PATCH 2/3] Add comments explaining event check-in behavior --- src/alembic/env.py | 1 + src/event_check_in/crud.py | 4 +++- src/event_check_in/models.py | 3 +++ src/event_check_in/tables.py | 3 +++ src/event_check_in/urls.py | 9 +++++++++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/alembic/env.py b/src/alembic/env.py index 8d682c5..e52bb1e 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -1,3 +1,4 @@ +# Register the check-in table in Base.metadata for Albemic autogeneration import event_check_in.tables import asyncio from logging.config import fileConfig diff --git a/src/event_check_in/crud.py b/src/event_check_in/crud.py index 16aa2a7..206d3d5 100644 --- a/src/event_check_in/crud.py +++ b/src/event_check_in/crud.py @@ -20,11 +20,12 @@ async def get_check_in( *, 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() @@ -37,6 +38,7 @@ def create_check_in( 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 diff --git a/src/event_check_in/models.py b/src/event_check_in/models.py index 3fa0599..5ccfe2a 100644 --- a/src/event_check_in/models.py +++ b/src/event_check_in/models.py @@ -19,6 +19,7 @@ def validate_time_range(self) -> Self: return self +# IDs and audit timestamps are returned to clients but are not writable inputs class EventCheckIn(EventCheckInCreate): model_config = ConfigDict(from_attributes=True) @@ -27,6 +28,7 @@ class EventCheckIn(EventCheckInCreate): updated_at: AwareDatetime +# PATCH keeps omitted fields unchanged; null can clear opens_at or closes_at class EventCheckInUpdate(BaseModel): model_config = ConfigDict(extra="forbid") @@ -35,6 +37,7 @@ class EventCheckInUpdate(BaseModel): 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: diff --git a/src/event_check_in/tables.py b/src/event_check_in/tables.py index 1b1de95..dca08a2 100644 --- a/src/event_check_in/tables.py +++ b/src/event_check_in/tables.py @@ -17,6 +17,7 @@ from database import Base +# Each row defines a checkin period, separate from individual attendance records class EventCheckInDB(Base): __tablename__ = "event_check_in" @@ -55,12 +56,14 @@ class EventCheckInDB(Base): 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", diff --git a/src/event_check_in/urls.py b/src/event_check_in/urls.py index 79f5e01..99bf1e4 100644 --- a/src/event_check_in/urls.py +++ b/src/event_check_in/urls.py @@ -27,6 +27,7 @@ async def _get_event_id(db_session: database.DBSession, eid: str) -> int: + # Convert the public event ID from the URL into the database's internal ID 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 not found.") @@ -38,8 +39,11 @@ async def _save_check_in( check_in: EventCheckInDB, ) -> EventCheckIn: try: + # Write pending changes and load database-generated IDs and timestamps await db_session.flush() await db_session.refresh(check_in) + + # Copy values into the response model before commit expires in the database object response = EventCheckIn.model_validate(check_in) await db_session.commit() except IntegrityError as error: @@ -119,9 +123,12 @@ async def update_check_in( ) current = EventCheckInCreate.model_validate(check_in, from_attributes=True) + + # Apply only supplied fields; explicit nulls can clear the optional time limits updated_data = current.model_dump() | body.model_dump(exclude_unset=True) try: + # Check the complete time range, including any unchanged opening or closing time validated = EventCheckInCreate.model_validate(updated_data) except ValidationError as error: raise HTTPException( @@ -144,6 +151,8 @@ async def delete_check_in( check_in_id: CheckInIdPath, ): event_id = await _get_event_id(db_session, eid) + + # For an existing event, deletion succeeds even if the configuration is already absent await event_check_in.crud.delete_check_in(db_session, event_id, check_in_id) await db_session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) From 9abaddd61a187c11bedbf7cc654f9f1021e2f131 Mon Sep 17 00:00:00 2001 From: Danny Choi Date: Tue, 27 Oct 2026 17:31:16 -0700 Subject: [PATCH 3/3] Apply Ruff formatting to check-in migration --- src/alembic/env.py | 3 +- .../2441c037abe2_add_event_check_in_table.py | 45 +++++++++++-------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/alembic/env.py b/src/alembic/env.py index 47200ef..9320de3 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -1,5 +1,3 @@ -# Register the check-in table in Base.metadata for Albemic autogeneration -import event_check_in.tables import asyncio from logging.config import fileConfig @@ -13,6 +11,7 @@ import database import elections.tables import event.tables +import event_check_in.tables # Register the check-in table for Alembic autogeneration. import honorary.tables import image_asset.tables import nominees.tables diff --git a/src/alembic/versions/2441c037abe2_add_event_check_in_table.py b/src/alembic/versions/2441c037abe2_add_event_check_in_table.py index 6b9d505..c59d80a 100644 --- a/src/alembic/versions/2441c037abe2_add_event_check_in_table.py +++ b/src/alembic/versions/2441c037abe2_add_event_check_in_table.py @@ -5,40 +5,47 @@ Create Date: 2026-10-24 15:18:42.382537 """ -from typing import Sequence -from alembic import op +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' +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), - 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), - 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_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), + 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), + 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) + 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') + 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 ###