Repository navigation
Add event check-in configuration API #208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
ba7409a
8142ab3
44f7335
9abaddd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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), | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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 ### | ||||||||||
| 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) |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have the fields in the models be |
| 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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Define a constant for the |
||
| 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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| 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)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
| ), | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove the comment