From db9229e7634e53d1ad189687782b130ebea0c70a Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 16:33:03 -0700 Subject: [PATCH 01/71] refactor: drop TournamentMembership.assigned_event_id and .schedule --- ...6b7c8_tournament_events_backend_rewrite.py | 59 +++++++++++++++++++ .../app/api/routes/tournament/memberships.py | 6 +- backend/app/models/models.py | 8 --- backend/app/schemas/tournament/membership.py | 9 --- .../tests/api/tournament/test_memberships.py | 15 ----- 5 files changed, 61 insertions(+), 36 deletions(-) create mode 100644 backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py diff --git a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py new file mode 100644 index 00000000..45c894a4 --- /dev/null +++ b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py @@ -0,0 +1,59 @@ +"""tournament events backend rewrite + +Revision ID: d3e4f5a6b7c8 +Revises: c7b3f4a9d2e1 +Create Date: 2026-08-13 00:00:00.000000 + +Single migration for the tournament-events backend rewrite, built up +incrementally as the rewrite progresses: + + - tournament_memberships: drop `assigned_event_id` column (and its FK to + tournament_events) + - tournament_memberships: drop `schedule` column + No backfill — this data is not being preserved or migrated anywhere. +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = "d3e4f5a6b7c8" +down_revision: Union[str, None] = "c7b3f4a9d2e1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ------------------------------------------------------------------ + # tournament_memberships — drop assigned_event_id + schedule + # ------------------------------------------------------------------ + op.drop_constraint( + "memberships_assigned_event_id_fkey", + "tournament_memberships", + type_="foreignkey", + ) + op.drop_column("tournament_memberships", "assigned_event_id") + op.drop_column("tournament_memberships", "schedule") + + +def downgrade() -> None: + # ------------------------------------------------------------------ + # tournament_memberships — restore assigned_event_id + schedule + # NOTE: data will be lost on downgrade. + # ------------------------------------------------------------------ + op.add_column( + "tournament_memberships", + sa.Column("schedule", sa.JSON(), nullable=True), + ) + op.add_column( + "tournament_memberships", + sa.Column("assigned_event_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "memberships_assigned_event_id_fkey", + "tournament_memberships", + "tournament_events", + ["assigned_event_id"], + ["id"], + ondelete="SET NULL", + ) diff --git a/backend/app/api/routes/tournament/memberships.py b/backend/app/api/routes/tournament/memberships.py index a569b0b2..43231b6c 100644 --- a/backend/app/api/routes/tournament/memberships.py +++ b/backend/app/api/routes/tournament/memberships.py @@ -195,7 +195,7 @@ def get_membership( # --------------------------------------------------------------------------- # PATCH /tournaments/{tournament_id}/memberships/me/ — self-service # Lets a volunteer update their own onboarding responses. Cannot touch -# day-of logistics (schedule, notes) — that's manage_members-only. +# day-of logistics (notes) — that's manage_members-only. # --------------------------------------------------------------------------- @router.patch("/me/", response_model=MembershipFullResponse) def update_my_membership( @@ -225,7 +225,7 @@ def update_my_membership( # --------------------------------------------------------------------------- # PATCH /tournaments/{tournament_id}/memberships/{membership_id} — manage_members (rank-bound) -# Staff override — day-of logistics only (schedule, notes). Not onboarding +# Staff override — day-of logistics only (notes). Not onboarding # data; that's self-service via PATCH .../me/. # --------------------------------------------------------------------------- @router.patch("/{membership_id}/", response_model=MembershipFullResponse) @@ -243,8 +243,6 @@ def update_membership( validate_member_target(current_user, tournament, m, db) update_data = payload.model_dump(exclude_none=True) - if "schedule" in update_data and payload.schedule: - update_data["schedule"] = [s.model_dump() for s in payload.schedule] for field, value in update_data.items(): setattr(m, field, value) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 6b692cb7..289d00db 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -305,10 +305,6 @@ class TournamentMembership(Base): tournament_id = Column( Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False ) - assigned_event_id = Column( - Integer, ForeignKey("tournament_events.id", ondelete="SET NULL"), nullable=True - ) - source = Column(String(32), nullable=False) # "join_code" | "public" | "manual" # Join code redeemed, if source=="join_code". SET NULL on code delete so @@ -317,8 +313,6 @@ class TournamentMembership(Base): Integer, ForeignKey("join_codes.id", ondelete="SET NULL"), nullable=True ) - schedule = Column(JSON, nullable=True) # [{block, duty}, ...] — day-of assignments - # "interested" | "confirmed" status = Column(String(32), nullable=False, default="interested") @@ -340,7 +334,6 @@ class TournamentMembership(Base): # Relationships user = relationship("User", back_populates="memberships") tournament = relationship("Tournament", back_populates="memberships") - assigned_event = relationship("TournamentEvent", back_populates="memberships") roles = relationship("TournamentMembershipRole", back_populates="membership", cascade="all, delete-orphan") join_code = relationship("JoinCode") @@ -493,7 +486,6 @@ class TournamentEvent(Base): updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) tournament = relationship("Tournament", back_populates="events") - memberships = relationship("TournamentMembership", back_populates="assigned_event") __table_args__ = ( UniqueConstraint("tournament_id", "name", "division", name="uq_tournament_event_division"), diff --git a/backend/app/schemas/tournament/membership.py b/backend/app/schemas/tournament/membership.py index d2e31350..ac136d5b 100644 --- a/backend/app/schemas/tournament/membership.py +++ b/backend/app/schemas/tournament/membership.py @@ -16,12 +16,6 @@ class AvailabilitySlot(BaseModel): end: str # "HH:MM" -class ScheduleSlot(BaseModel): - """A single day-of block assignment.""" - block: int # block number - duty: str # role label or free string, e.g. "Event Supervisor" - - class MembershipMeUpdate(BaseModel): """Self-service update — the fields a volunteer fills out during onboarding. @@ -36,7 +30,6 @@ class MembershipMeUpdate(BaseModel): class MembershipCoordinatorUpdate(BaseModel): """manage_members override — day-of logistics only, not onboarding data.""" - schedule: list[ScheduleSlot] | None = None notes: Optional[str] = None @@ -105,8 +98,6 @@ class MembershipFullResponse(_MembershipRolesMixin): """Detail view — the expanded side panel for a single member.""" id: int tournament_id: int - assigned_event_id: int | None = None - schedule: list[ScheduleSlot] | None = None status: str role_preference: list[str] | None = None event_preference: list[str] | None = None diff --git a/backend/tests/api/tournament/test_memberships.py b/backend/tests/api/tournament/test_memberships.py index 8010ae7e..acae2e0a 100644 --- a/backend/tests/api/tournament/test_memberships.py +++ b/backend/tests/api/tournament/test_memberships.py @@ -470,21 +470,6 @@ def test_update_my_membership_invalid_availability_shape(client, td_tournament, # Coordinator update — PATCH .../{membership_id}/ # --------------------------------------------------------------------------- -def test_update_membership_schedule(client, td_user, td_tournament, db): - u = _make_user(db) - m = _make_membership(db, td_tournament.id, u["id"]) - login(client, "td@test.com", "tdpass") - response = client.patch( - f"/tournaments/{td_tournament.id}/memberships/{m.id}/", - json={"schedule": [ - {"block": 1, "duty": "event_supervisor"}, - {"block": 7, "duty": "scoring"}, - ]}, - ) - assert response.status_code == 200 - assert response.json()["schedule"][1]["duty"] == "scoring" - - def test_update_membership_notes(client, td_user, td_tournament, db): u = _make_user(db) m = _make_membership(db, td_tournament.id, u["id"]) From 1fbd317d1eb51e9fafa4a753d2e5e9e12861ef0a Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 16:35:18 -0700 Subject: [PATCH 02/71] feat(shift): add TournamentShift model and tournament_event_shifts bridge table --- ...6b7c8_tournament_events_backend_rewrite.py | 28 +++++++++++++++ backend/app/models/models.py | 36 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py index 45c894a4..4483a4b0 100644 --- a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py +++ b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py @@ -11,6 +11,9 @@ tournament_events) - tournament_memberships: drop `schedule` column No backfill — this data is not being preserved or migrated anywhere. + + - tournament_shifts: new table — tournament-scoped time windows + - tournament_event_shifts: new bridge table — TournamentEvent <-> TournamentShift """ from typing import Sequence, Union from alembic import op @@ -35,8 +38,33 @@ def upgrade() -> None: op.drop_column("tournament_memberships", "assigned_event_id") op.drop_column("tournament_memberships", "schedule") + # ------------------------------------------------------------------ + # tournament_shifts + tournament_event_shifts — new tables + # ------------------------------------------------------------------ + op.create_table( + "tournament_shifts", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("tournament_id", sa.Integer(), sa.ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False), + sa.Column("label", sa.String(length=255), nullable=False), + sa.Column("start", sa.DateTime(timezone=True), nullable=False), + sa.Column("end", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_table( + "tournament_event_shifts", + sa.Column("tournament_event_id", sa.Integer(), sa.ForeignKey("tournament_events.id", ondelete="CASCADE"), primary_key=True), + sa.Column("tournament_shift_id", sa.Integer(), sa.ForeignKey("tournament_shifts.id", ondelete="CASCADE"), primary_key=True), + ) + def downgrade() -> None: + # ------------------------------------------------------------------ + # tournament_shifts + tournament_event_shifts — drop new tables + # ------------------------------------------------------------------ + op.drop_table("tournament_event_shifts") + op.drop_table("tournament_shifts") + # ------------------------------------------------------------------ # tournament_memberships — restore assigned_event_id + schedule # NOTE: data will be lost on downgrade. diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 289d00db..9fe7bd42 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -275,6 +275,7 @@ class Tournament(Base): roles = relationship("TournamentRole", back_populates="tournament", cascade="all, delete-orphan") join_codes = relationship("JoinCode", back_populates="tournament", cascade="all, delete-orphan") audit_log = relationship("AuditLogEntry", back_populates="tournament", cascade="all, delete-orphan") + event_shifts = relationship("TournamentShift", back_populates="tournament", cascade="all, delete-orphan") # Exactly one of university_id/location (XOR). Checked at flush, not @@ -486,12 +487,47 @@ class TournamentEvent(Base): updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) tournament = relationship("Tournament", back_populates="events") + shifts = relationship( + "TournamentShift", secondary="tournament_event_shifts", back_populates="tournament_events" + ) __table_args__ = ( UniqueConstraint("tournament_id", "name", "division", name="uq_tournament_event_division"), ) +# --------------------------------------------------------------------------- +# TournamentShift — a tournament-scoped time window (e.g. "Shift 1, 8am-12pm") +# that can be attached to one or more TournamentEvents via the +# tournament_event_shifts bridge table. +# --------------------------------------------------------------------------- +class TournamentShift(Base): + __tablename__ = "tournament_shifts" + + id = Column(Integer, primary_key=True, index=True) + tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False) + label = Column(String(255), nullable=False) + start = Column(DateTime(timezone=True), nullable=False) + end = Column(DateTime(timezone=True), nullable=False) + created_at = Column(DateTime(timezone=True), default=utcnow) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + tournament = relationship("Tournament", back_populates="event_shifts") + tournament_events = relationship( + "TournamentEvent", secondary="tournament_event_shifts", back_populates="shifts" + ) + + +# --------------------------------------------------------------------------- +# TournamentEventShift — bridge table: TournamentEvent <-> TournamentShift. +# --------------------------------------------------------------------------- +class TournamentEventShift(Base): + __tablename__ = "tournament_event_shifts" + + tournament_event_id = Column(Integer, ForeignKey("tournament_events.id", ondelete="CASCADE"), primary_key=True) + tournament_shift_id = Column(Integer, ForeignKey("tournament_shifts.id", ondelete="CASCADE"), primary_key=True) + + # --------------------------------------------------------------------------- # SheetConfig # --------------------------------------------------------------------------- From 295ab39f9c2e26eb34d4b6c67b7e01fa8d442e44 Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 16:46:45 -0700 Subject: [PATCH 03/71] =?UTF-8?q?feat(event):=20rewrite=20TournamentEvent?= =?UTF-8?q?=20table=20=E2=80=94=20drop=20blocks/category,=20link=20to=20ca?= =?UTF-8?q?nonical=20Event=20catalog,=20add=20start/end=20times?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6b7c8_tournament_events_backend_rewrite.py | 89 +++++++++++++++++++ backend/app/models/models.py | 31 ++++--- 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py index 4483a4b0..d499d96d 100644 --- a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py +++ b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py @@ -14,10 +14,27 @@ - tournament_shifts: new table — tournament-scoped time windows - tournament_event_shifts: new bridge table — TournamentEvent <-> TournamentShift + + - tournament_events: drop `blocks` and `category` columns (no migration + path — old free-text categories don't reliably match canonical + category names, and the old block-scheduling system is gone, not + migrated to shifts) + - tournament_events: `division` and `name` made nullable (`name` is now + custom-event-only display text — catalog-linked events display the + joined Event.name instead) + - tournament_events: add `event_id` FK to events (SET NULL), nullable + - tournament_events: add `start_time`/`end_time`, backfilled from the + parent tournament's start_date/end_date (else now()) since existing + rows have no equivalent data — flagged for TD review via a one-off + `# TODO(temp)` data note printed during migration, not a new column + - tournament_events: drop `uq_tournament_event_division`, add a partial + unique index on (tournament_id, event_id, division) WHERE event_id IS + NOT NULL — custom (event_id-less) events have no uniqueness constraint """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +from sqlalchemy import text revision: str = "d3e4f5a6b7c8" @@ -57,8 +74,80 @@ def upgrade() -> None: sa.Column("tournament_shift_id", sa.Integer(), sa.ForeignKey("tournament_shifts.id", ondelete="CASCADE"), primary_key=True), ) + # ------------------------------------------------------------------ + # tournament_events — drop blocks/category, add event_id, add + # start_time/end_time (backfilled), relax division, swap the unique + # constraint for a partial unique index scoped to catalog-linked rows + # ------------------------------------------------------------------ + op.drop_constraint("uq_tournament_event_division", "tournament_events", type_="unique") + op.alter_column("tournament_events", "division", existing_type=sa.String(length=4), nullable=True) + op.alter_column("tournament_events", "name", existing_type=sa.String(length=255), nullable=True) + + op.drop_column("tournament_events", "blocks") + op.drop_column("tournament_events", "category") + + op.add_column( + "tournament_events", + sa.Column("event_id", sa.Integer(), sa.ForeignKey("events.id", ondelete="SET NULL"), nullable=True), + ) + + # start_time/end_time: add nullable first so the backfill can run, then + # tighten to NOT NULL once every row has a value. + op.add_column("tournament_events", sa.Column("start_time", sa.DateTime(timezone=True), nullable=True)) + op.add_column("tournament_events", sa.Column("end_time", sa.DateTime(timezone=True), nullable=True)) + + conn = op.get_bind() + # TODO(temp): backfilled from the parent tournament's date range since + # old `blocks` was a scheduling mechanism, not a datetime range — TD + # should review/adjust these once the events UI ships. Flag in PR. + result = conn.execute( + text( + """ + UPDATE tournament_events te + SET start_time = COALESCE(t.start_date::timestamptz, now()), + end_time = COALESCE(t.end_date::timestamptz, now()) + FROM tournaments t + WHERE t.id = te.tournament_id + """ + ) + ) + print(f"[d3e4f5a6b7c8] backfilled start_time/end_time on {result.rowcount} tournament_events row(s) " + f"from parent tournament dates — needs TD review, see TODO(temp) in this migration") + + op.alter_column("tournament_events", "start_time", existing_type=sa.DateTime(timezone=True), nullable=False) + op.alter_column("tournament_events", "end_time", existing_type=sa.DateTime(timezone=True), nullable=False) + + op.create_index( + "uq_tournament_event_catalog_division", + "tournament_events", + ["tournament_id", "event_id", "division"], + unique=True, + postgresql_where=text("event_id IS NOT NULL"), + ) + def downgrade() -> None: + # ------------------------------------------------------------------ + # tournament_events — restore blocks/category/old unique constraint, + # drop event_id/start_time/end_time + # NOTE: data will be lost on downgrade (event_id links, start/end times). + # ------------------------------------------------------------------ + op.drop_index("uq_tournament_event_catalog_division", table_name="tournament_events") + + op.drop_column("tournament_events", "end_time") + op.drop_column("tournament_events", "start_time") + op.drop_column("tournament_events", "event_id") + + op.add_column("tournament_events", sa.Column("category", sa.String(length=255), nullable=True)) + op.add_column("tournament_events", sa.Column("blocks", sa.JSON(), nullable=False, server_default="[]")) + op.alter_column("tournament_events", "blocks", server_default=None) + + op.alter_column("tournament_events", "division", existing_type=sa.String(length=4), nullable=False) + op.alter_column("tournament_events", "name", existing_type=sa.String(length=255), nullable=False) + op.create_unique_constraint( + "uq_tournament_event_division", "tournament_events", ["tournament_id", "name", "division"] + ) + # ------------------------------------------------------------------ # tournament_shifts + tournament_event_shifts — drop new tables # ------------------------------------------------------------------ diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 9fe7bd42..87d6d03d 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone from sqlalchemy import ( Integer, String, Text, Boolean, Date, DateTime, JSON, - ForeignKey, UniqueConstraint, CheckConstraint, Column, event, + ForeignKey, UniqueConstraint, CheckConstraint, Column, event, Index, ) from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship @@ -470,29 +470,40 @@ class TournamentEvent(Base): Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False ) - name = Column(String(255), nullable=False) - division = Column(String(4), nullable=False) # "B" | "C" + # Custom (event_id-less) events only + name = Column(String(255), nullable=True) + division = Column(String(4), nullable=True) # "A" | "B" | "C" event_type = Column(String(32), nullable=False, default="standard") # "standard" | "trial" - category = Column(String(255), nullable=True) - + + # Canonical event table link — SET NULL on delete so custom (event_id-less) + # events are just the default, not a broken reference. + event_id = Column(Integer, ForeignKey("events.id", ondelete="SET NULL"), nullable=True) + building = Column(String(255), nullable=True) room = Column(String(64), nullable=True) floor = Column(String(64), nullable=True) - + volunteers_needed = Column(Integer, nullable=False, default=2) - - blocks = Column(JSON, nullable=False, default=list) # [1,2,3,4,5,6] - + + start_time = Column(DateTime(timezone=True), nullable=False) + end_time = Column(DateTime(timezone=True), nullable=False) + created_at = Column(DateTime(timezone=True), default=utcnow) updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) tournament = relationship("Tournament", back_populates="events") + event = relationship("Event") shifts = relationship( "TournamentShift", secondary="tournament_event_shifts", back_populates="tournament_events" ) __table_args__ = ( - UniqueConstraint("tournament_id", "name", "division", name="uq_tournament_event_division"), + Index( + "uq_tournament_event_catalog_division", + "tournament_id", "event_id", "division", + unique=True, + postgresql_where=(event_id.isnot(None)), + ), ) From 858ebac23067b5ff7663903c8620b9f09382b646 Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 16:50:13 -0700 Subject: [PATCH 04/71] =?UTF-8?q?feat(event):=20add=20SeasonEvent=20table?= =?UTF-8?q?=20=E2=80=94=20admin-curated=20per-year/division=20active=20eve?= =?UTF-8?q?nt=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6b7c8_tournament_events_backend_rewrite.py | 23 +++++++++++++++++++ backend/app/models/models.py | 22 ++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py index d499d96d..b6d20411 100644 --- a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py +++ b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py @@ -30,6 +30,10 @@ - tournament_events: drop `uq_tournament_event_division`, add a partial unique index on (tournament_id, event_id, division) WHERE event_id IS NOT NULL — custom (event_id-less) events have no uniqueness constraint + + - season_events: new table — admin-curated per-year/division active + event list, drives the tournament events bulk-load default list. No + backfill, starts empty. """ from typing import Sequence, Union from alembic import op @@ -125,8 +129,27 @@ def upgrade() -> None: postgresql_where=text("event_id IS NOT NULL"), ) + # ------------------------------------------------------------------ + # season_events — new table + # ------------------------------------------------------------------ + op.create_table( + "season_events", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("event_id", sa.Integer(), sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False), + sa.Column("year", sa.Integer(), nullable=False), + sa.Column("division", sa.String(length=4), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("event_id", "year", "division", name="uq_season_event"), + ) + def downgrade() -> None: + # ------------------------------------------------------------------ + # season_events — drop new table + # ------------------------------------------------------------------ + op.drop_table("season_events") + # ------------------------------------------------------------------ # tournament_events — restore blocks/category/old unique constraint, # drop event_id/start_time/end_time diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 87d6d03d..e44c69f0 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -539,6 +539,28 @@ class TournamentEventShift(Base): tournament_shift_id = Column(Integer, ForeignKey("tournament_shifts.id", ondelete="CASCADE"), primary_key=True) +# --------------------------------------------------------------------------- +# SeasonEvent — admin-curated "this canonical event, in this division, is +# active this year" list. Drives the tournament events bulk-load default +# list (see TournamentEvent); independent of any single tournament. +# --------------------------------------------------------------------------- +class SeasonEvent(Base): + __tablename__ = "season_events" + + id = Column(Integer, primary_key=True, index=True) + event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False) + year = Column(Integer, nullable=False) + division = Column(String(4), nullable=False) + is_active = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime(timezone=True), default=utcnow) + + event = relationship("Event") + + __table_args__ = ( + UniqueConstraint("event_id", "year", "division", name="uq_season_event"), + ) + + # --------------------------------------------------------------------------- # SheetConfig # --------------------------------------------------------------------------- From 6d65c47e78908c8c05536cb4ca50ec967c8230d7 Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 16:57:27 -0700 Subject: [PATCH 05/71] feat(event): rewrite tournament event schemas for canonical catalog links, add TournamentShift and SeasonEvent schemas --- backend/app/schemas/season_event.py | 46 ++++++++++++ backend/app/schemas/tournament/event.py | 97 +++++++++++++++++++------ backend/app/schemas/tournament/shift.py | 39 ++++++++++ 3 files changed, 159 insertions(+), 23 deletions(-) create mode 100644 backend/app/schemas/season_event.py create mode 100644 backend/app/schemas/tournament/shift.py diff --git a/backend/app/schemas/season_event.py b/backend/app/schemas/season_event.py new file mode 100644 index 00000000..56913d1d --- /dev/null +++ b/backend/app/schemas/season_event.py @@ -0,0 +1,46 @@ +from __future__ import annotations +from datetime import datetime +from pydantic import BaseModel, field_validator + +from app.schemas.event import EventResponse +from app.schemas.tournament import VALID_DIVISIONS + + +class SeasonEventCreate(BaseModel): + event_id: int + year: int + division: str + is_active: bool = False + + @field_validator("division") + @classmethod + def validate_division(cls, v: str) -> str: + if v not in VALID_DIVISIONS: + raise ValueError(f"division must be one of: {VALID_DIVISIONS}") + return v + + +class SeasonEventUpdate(BaseModel): + """Partial update — primarily used to toggle is_active.""" + year: int | None = None + division: str | None = None + is_active: bool | None = None + + @field_validator("division") + @classmethod + def validate_division(cls, v: str | None) -> str | None: + if v is not None and v not in VALID_DIVISIONS: + raise ValueError(f"division must be one of: {VALID_DIVISIONS}") + return v + + +class SeasonEventRead(BaseModel): + id: int + event_id: int + year: int + division: str + is_active: bool + event: EventResponse + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/tournament/event.py b/backend/app/schemas/tournament/event.py index 064d6c0d..e4f39049 100644 --- a/backend/app/schemas/tournament/event.py +++ b/backend/app/schemas/tournament/event.py @@ -1,28 +1,32 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, field_validator, model_validator + +from app.schemas.event import EventResponse +from app.schemas.tournament import VALID_DIVISIONS +from app.schemas.tournament.shift import TournamentShiftRead -VALID_DIVISIONS = {"B", "C"} VALID_EVENT_TYPES = {"standard", "trial"} class EventBase(BaseModel): - name: str - division: str + # Custom (event_id-less) events only — catalog-linked events display + # the joined Event.name instead. See the model_validator below. + name: str | None = None + division: str | None = None event_type: str = "standard" - category: str | None = None + event_id: int | None = None building: str | None = None room: str | None = None floor: str | None = None volunteers_needed: int = 2 - # Block numbers this event runs e.g. [1,2,3,4,5,6] - # Empty list means the TD hasn't configured blocks yet - blocks: list[int] = [] + start_time: datetime + end_time: datetime @field_validator("division") @classmethod - def validate_division(cls, v: str) -> str: - if v not in VALID_DIVISIONS: + def validate_division(cls, v: str | None) -> str | None: + if v is not None and v not in VALID_DIVISIONS: raise ValueError(f"division must be one of: {VALID_DIVISIONS}") return v @@ -40,14 +44,17 @@ def validate_volunteers_needed(cls, v: int) -> int: raise ValueError("volunteers_needed must be at least 1") return v - @field_validator("blocks") - @classmethod - def validate_blocks(cls, v: list[int]) -> list[int]: - if len(v) != len(set(v)): - raise ValueError("Block numbers must be unique") - if any(b < 1 for b in v): - raise ValueError("Block numbers must be positive integers") - return sorted(v) + @model_validator(mode="after") + def validate_times(self) -> "EventBase": + if self.end_time <= self.start_time: + raise ValueError("end_time must be after start_time") + return self + + @model_validator(mode="after") + def validate_name_or_event_id(self) -> "EventBase": + if self.name is None and self.event_id is None: + raise ValueError("at least one of name or event_id must be set") + return self class EventCreate(EventBase): @@ -55,22 +62,66 @@ class EventCreate(EventBase): class EventUpdate(BaseModel): - """Partial update — all fields optional.""" + """Partial update — all fields optional. Only the fields actually sent + are validated against each other (mirrors current + incoming values is + the route's job, not this schema's).""" name: str | None = None division: str | None = None event_type: str | None = None - category: str | None = None + event_id: int | None = None building: str | None = None room: str | None = None floor: str | None = None volunteers_needed: int | None = None - blocks: list[int] | None = None + start_time: datetime | None = None + end_time: datetime | None = None + + @field_validator("division") + @classmethod + def validate_division(cls, v: str | None) -> str | None: + if v is not None and v not in VALID_DIVISIONS: + raise ValueError(f"division must be one of: {VALID_DIVISIONS}") + return v + + @field_validator("event_type") + @classmethod + def validate_event_type(cls, v: str | None) -> str | None: + if v is not None and v not in VALID_EVENT_TYPES: + raise ValueError(f"event_type must be one of: {VALID_EVENT_TYPES}") + return v + + @field_validator("volunteers_needed") + @classmethod + def validate_volunteers_needed(cls, v: int | None) -> int | None: + if v is not None and v < 1: + raise ValueError("volunteers_needed must be at least 1") + return v + @model_validator(mode="after") + def validate_times(self) -> "EventUpdate": + if self.start_time is not None and self.end_time is not None and self.end_time <= self.start_time: + raise ValueError("end_time must be after start_time") + return self -class EventRead(EventBase): + +class EventRead(BaseModel): id: int tournament_id: int + name: str | None = None + division: str | None = None + event_type: str + event_id: int | None = None + # Joined canonical event — set only when event_id is set. Carries + # category, since TournamentEvent no longer has its own category field. + event: EventResponse | None = None + building: str | None = None + room: str | None = None + floor: str | None = None + volunteers_needed: int + start_time: datetime + end_time: datetime + shifts: list[TournamentShiftRead] = [] created_at: datetime updated_at: datetime - model_config = {"from_attributes": True} \ No newline at end of file + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/tournament/shift.py b/backend/app/schemas/tournament/shift.py new file mode 100644 index 00000000..5d647dec --- /dev/null +++ b/backend/app/schemas/tournament/shift.py @@ -0,0 +1,39 @@ +from __future__ import annotations +from datetime import datetime +from pydantic import BaseModel, model_validator + + +class TournamentShiftCreate(BaseModel): + label: str + start: datetime + end: datetime + + @model_validator(mode="after") + def validate_times(self) -> "TournamentShiftCreate": + if self.end <= self.start: + raise ValueError("end must be after start") + return self + + +class TournamentShiftUpdate(BaseModel): + label: str | None = None + start: datetime | None = None + end: datetime | None = None + + @model_validator(mode="after") + def validate_times(self) -> "TournamentShiftUpdate": + if self.start is not None and self.end is not None and self.end <= self.start: + raise ValueError("end must be after start") + return self + + +class TournamentShiftRead(BaseModel): + id: int + tournament_id: int + label: str + start: datetime + end: datetime + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} From 4f98969798d38687111c3f6f5f6eeeeef3f0f5d9 Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 17:02:50 -0700 Subject: [PATCH 06/71] feat(event): update tournament events routes/tests for canonical catalog links and start/end times --- ...6b7c8_tournament_events_backend_rewrite.py | 3 +- backend/app/api/routes/tournament/events.py | 80 ++++++++++-------- backend/tests/api/tournament/test_events.py | 84 +++++++++++++++++-- 3 files changed, 121 insertions(+), 46 deletions(-) diff --git a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py index b6d20411..a415f345 100644 --- a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py +++ b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py @@ -25,8 +25,7 @@ - tournament_events: add `event_id` FK to events (SET NULL), nullable - tournament_events: add `start_time`/`end_time`, backfilled from the parent tournament's start_date/end_date (else now()) since existing - rows have no equivalent data — flagged for TD review via a one-off - `# TODO(temp)` data note printed during migration, not a new column + rows have no equivalent data - tournament_events: drop `uq_tournament_event_division`, add a partial unique index on (tournament_id, event_id, division) WHERE event_id IS NOT NULL — custom (event_id-less) events have no uniqueness constraint diff --git a/backend/app/api/routes/tournament/events.py b/backend/app/api/routes/tournament/events.py index cd1d6c39..5e89fce6 100644 --- a/backend/app/api/routes/tournament/events.py +++ b/backend/app/api/routes/tournament/events.py @@ -1,6 +1,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, joinedload from app.core.tournament import get_scoped_or_404, get_tournament, require_not_archived from app.core.tournament.permissions import MANAGE_EVENTS, require_permission @@ -13,22 +14,15 @@ router = APIRouter(prefix="/tournaments/{tournament_id}/events", tags=["tournaments"]) -def _serialize(event: TournamentEvent) -> dict: - return { - "id": event.id, - "tournament_id": event.tournament_id, - "name": event.name, - "division": event.division, - "event_type": event.event_type, - "category": event.category, - "building": event.building, - "room": event.room, - "floor": event.floor, - "volunteers_needed": event.volunteers_needed, - "blocks": event.blocks or [], - "created_at": event.created_at, - "updated_at": event.updated_at, - } +def _validate_division(division: str | None, tournament) -> None: + """A set division must be one of the divisions the tournament itself + supports. SeasonEvent plays no role here — it's independent of what's + "suggested" for the tournament.""" + if division is not None and division not in (tournament.division or []): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"division must be one of the tournament's divisions: {tournament.division}", + ) # --------------------------------------------------------------------------- @@ -45,11 +39,12 @@ def list_events( events = ( db.query(TournamentEvent) + .options(joinedload(TournamentEvent.event), joinedload(TournamentEvent.shifts)) .filter(TournamentEvent.tournament_id == tournament_id) .order_by(TournamentEvent.division, TournamentEvent.name) .all() ) - return [_serialize(e) for e in events] + return events # --------------------------------------------------------------------------- @@ -62,7 +57,7 @@ def get_event( db: Session = Depends(get_db), current_user: User = Depends(require_permission(MANAGE_EVENTS)), ): - return _serialize(get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event")) + return get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event") # --------------------------------------------------------------------------- @@ -85,22 +80,20 @@ def create_event( detail="tournament_id in body does not match URL", ) - existing = db.query(TournamentEvent).filter( - TournamentEvent.tournament_id == tournament_id, - TournamentEvent.name == payload.name, - TournamentEvent.division == payload.division, - ).first() - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Event '{payload.name}' division {payload.division} already exists in this tournament", - ) + _validate_division(payload.division, tournament) event = TournamentEvent(**payload.model_dump()) db.add(event) - db.commit() + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This catalog event already exists in this tournament for this division", + ) db.refresh(event) - return _serialize(event) + return event # --------------------------------------------------------------------------- @@ -119,12 +112,29 @@ def update_event( event = get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event") - for field, value in payload.model_dump(exclude_none=True).items(): + update_data = payload.model_dump(exclude_unset=True) + if "division" in update_data: + _validate_division(update_data["division"], tournament) + + for field, value in update_data.items(): setattr(event, field, value) - db.commit() + if event.name is None and event.event_id is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot clear both name and event_id — at least one must be set", + ) + + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This catalog event already exists in this tournament for this division", + ) db.refresh(event) - return _serialize(event) + return event # --------------------------------------------------------------------------- @@ -142,4 +152,4 @@ def delete_event( event = get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event") db.delete(event) - db.commit() \ No newline at end of file + db.commit() diff --git a/backend/tests/api/tournament/test_events.py b/backend/tests/api/tournament/test_events.py index 9c81f48f..134adccf 100644 --- a/backend/tests/api/tournament/test_events.py +++ b/backend/tests/api/tournament/test_events.py @@ -9,7 +9,8 @@ def _make_event(client, tournament_id, **overrides): "tournament_id": tournament_id, "name": "Boomilever", "division": "C", - "blocks": [1, 2, 3, 4, 5, 6], + "start_time": "2026-03-14T08:00:00Z", + "end_time": "2026-03-14T12:00:00Z", } payload.update(overrides) return client.post(f"/tournaments/{tournament_id}/events/", json=payload) @@ -35,20 +36,64 @@ def test_create_event_full(client, td_user, td_tournament): login(client, "td@test.com", "tdpass") response = _make_event(client, td_tournament.id, name="Hovercraft", division="B", event_type="trial", - category="Technology & Engineering", building="Main Hall", - room="101", floor="1", volunteers_needed=3, blocks=[1, 2, 3], + building="Main Hall", room="101", floor="1", volunteers_needed=3, ) assert response.status_code == 201 data = response.json() - assert data["category"] == "Technology & Engineering" assert data["volunteers_needed"] == 3 assert data["event_type"] == "trial" -def test_create_event_duplicate_rejected(client, td_user, td_tournament): +def test_create_event_with_catalog_link_inherits_category(client, td_user, td_tournament, event): + """Setting event_id joins the canonical Event — category comes from + the join, not a column on TournamentEvent.""" login(client, "td@test.com", "tdpass") - _make_event(client, td_tournament.id) - assert _make_event(client, td_tournament.id).status_code == 409 + response = _make_event(client, td_tournament.id, name=None, event_id=event.id) + assert response.status_code == 201 + data = response.json() + assert data["event_id"] == event.id + assert data["event"]["category"]["name"] == event.category.name + + +def test_create_event_without_catalog_link_has_no_category(client, td_user, td_tournament): + """Custom (event_id-less) events have no category — nothing fabricated.""" + login(client, "td@test.com", "tdpass") + response = _make_event(client, td_tournament.id) + assert response.status_code == 201 + assert response.json()["event"] is None + + +def test_create_event_duplicate_catalog_division_rejected(client, td_user, td_tournament, event): + login(client, "td@test.com", "tdpass") + first = _make_event(client, td_tournament.id, name=None, event_id=event.id, division="C") + assert first.status_code == 201 + second = _make_event(client, td_tournament.id, name=None, event_id=event.id, division="C") + assert second.status_code == 409 + + +def test_create_event_two_custom_events_same_name_both_succeed(client, td_user, td_tournament): + """Custom events have no uniqueness constraint at all.""" + login(client, "td@test.com", "tdpass") + first = _make_event(client, td_tournament.id, name="Boomilever", division="C") + second = _make_event(client, td_tournament.id, name="Boomilever", division="C") + assert first.status_code == 201 + assert second.status_code == 201 + + +def test_create_event_division_not_in_tournament_divisions(client, td_user, td_tournament): + """td_tournament only supports divisions B/C.""" + login(client, "td@test.com", "tdpass") + response = _make_event(client, td_tournament.id, division="A") + assert response.status_code == 422 + + +def test_create_event_end_before_start_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_event( + client, td_tournament.id, + start_time="2026-03-14T12:00:00Z", end_time="2026-03-14T08:00:00Z", + ) + assert response.status_code == 422 def test_create_event_tournament_id_mismatch(client, td_user, td_tournament): @@ -57,7 +102,8 @@ def test_create_event_tournament_id_mismatch(client, td_user, td_tournament): "tournament_id": 9999, "name": "Boomilever", "division": "C", - "blocks": [], + "start_time": "2026-03-14T08:00:00Z", + "end_time": "2026-03-14T12:00:00Z", }) assert response.status_code == 400 @@ -162,6 +208,26 @@ def test_update_event(client, td_user, td_tournament): assert response.json()["building"] == "Science Hall" +def test_update_event_division_not_in_tournament_divisions(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_event(client, td_tournament.id).json() + response = client.patch( + f"/tournaments/{td_tournament.id}/events/{created['id']}/", + json={"division": "A"}, + ) + assert response.status_code == 422 + + +def test_update_event_end_before_start_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_event(client, td_tournament.id).json() + response = client.patch( + f"/tournaments/{td_tournament.id}/events/{created['id']}/", + json={"start_time": "2026-03-14T12:00:00Z", "end_time": "2026-03-14T08:00:00Z"}, + ) + assert response.status_code == 422 + + def test_update_event_volunteer_cannot_patch( client, td_user, other_user, other_tournament, db ): @@ -192,4 +258,4 @@ def test_delete_event(client, td_user, td_tournament): def test_delete_event_not_found(client, td_user, td_tournament): login(client, "td@test.com", "tdpass") - assert client.delete(f"/tournaments/{td_tournament.id}/events/9999/").status_code == 404 \ No newline at end of file + assert client.delete(f"/tournaments/{td_tournament.id}/events/9999/").status_code == 404 From 52be42c3018dcb8c1619dd5c0f28391c6c1a729b Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 17:06:12 -0700 Subject: [PATCH 07/71] feat(event): add TournamentShift CRUD routes and event/shift attach-detach with bounds and overlap validation --- backend/app/api/routes/tournament/shifts.py | 202 ++++++++++++++++++++ backend/app/main.py | 3 + backend/tests/api/tournament/test_shifts.py | 181 ++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 backend/app/api/routes/tournament/shifts.py create mode 100644 backend/tests/api/tournament/test_shifts.py diff --git a/backend/app/api/routes/tournament/shifts.py b/backend/app/api/routes/tournament/shifts.py new file mode 100644 index 00000000..9f8da676 --- /dev/null +++ b/backend/app/api/routes/tournament/shifts.py @@ -0,0 +1,202 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.tournament import get_scoped_or_404, get_tournament, require_not_archived +from app.core.tournament.permissions import MANAGE_EVENTS, require_permission +from app.db.session import get_db +from app.models.models import TournamentEvent, TournamentEventShift, TournamentShift, User +from app.schemas.tournament.shift import TournamentShiftCreate, TournamentShiftRead, TournamentShiftUpdate + +# Routes are nested: /tournaments/{tournament_id}/shifts/... +router = APIRouter(prefix="/tournaments/{tournament_id}/shifts", tags=["tournaments"]) + + +# --------------------------------------------------------------------------- +# GET /tournaments/{tournament_id}/shifts/ — manage_events +# --------------------------------------------------------------------------- +@router.get("/", response_model=list[TournamentShiftRead]) +def list_shifts( + tournament_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + get_tournament(tournament_id, db) + return ( + db.query(TournamentShift) + .filter(TournamentShift.tournament_id == tournament_id) + .order_by(TournamentShift.start) + .all() + ) + + +# --------------------------------------------------------------------------- +# POST /tournaments/{tournament_id}/shifts/ — manage_events +# --------------------------------------------------------------------------- +@router.post("/", response_model=TournamentShiftRead, status_code=status.HTTP_201_CREATED) +def create_shift( + tournament_id: int, + payload: TournamentShiftCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + shift = TournamentShift(tournament_id=tournament_id, **payload.model_dump()) + db.add(shift) + db.commit() + db.refresh(shift) + return shift + + +# --------------------------------------------------------------------------- +# PATCH /tournaments/{tournament_id}/shifts/{shift_id}/ — manage_events +# --------------------------------------------------------------------------- +@router.patch("/{shift_id}/", response_model=TournamentShiftRead) +def update_shift( + tournament_id: int, + shift_id: int, + payload: TournamentShiftUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + shift = get_scoped_or_404(db, TournamentShift, shift_id, tournament_id, "Shift") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(shift, field, value) + + db.commit() + db.refresh(shift) + return shift + + +# --------------------------------------------------------------------------- +# DELETE /tournaments/{tournament_id}/shifts/{shift_id}/ — manage_events +# No guard — cascades through tournament_event_shifts (ondelete="CASCADE"), +# silently detaching from any events it was attached to. Intentionally +# different from how TimeBlock deletion worked in the old scrapped design. +# --------------------------------------------------------------------------- +@router.delete("/{shift_id}/", status_code=status.HTTP_204_NO_CONTENT) +def delete_shift( + tournament_id: int, + shift_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + shift = get_scoped_or_404(db, TournamentShift, shift_id, tournament_id, "Shift") + db.delete(shift) + db.commit() + + +# --------------------------------------------------------------------------- +# Shift attachment — a sub-resource of events, so nested under +# /tournaments/{tournament_id}/events/{event_id}/shifts/. Single attach/detach +# endpoints per shift (not a batch add/remove like membership roles) because +# each attach carries its own bounds/overlap validation with a specific 409 +# reason — a batch call would need per-item partial failure reporting that +# isn't worth the complexity here. +# --------------------------------------------------------------------------- +event_shifts_router = APIRouter( + prefix="/tournaments/{tournament_id}/events/{event_id}/shifts", + tags=["tournaments"], +) + + +def _validate_attach(event: TournamentEvent, shift: TournamentShift, db: Session) -> None: + if shift.start < event.start_time or shift.end > event.end_time: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Shift must fall entirely within the event's start/end time", + ) + + attached = ( + db.query(TournamentShift) + .join(TournamentEventShift, TournamentEventShift.tournament_shift_id == TournamentShift.id) + .filter( + TournamentEventShift.tournament_event_id == event.id, + TournamentShift.id != shift.id, + ) + .all() + ) + for other in attached: + # Adjacent (end == start) is fine — only strict overlap is rejected. + if shift.start < other.end and shift.end > other.start: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Shift overlaps another shift already attached to this event ('{other.label}')", + ) + + +# --------------------------------------------------------------------------- +# POST /tournaments/{tournament_id}/events/{event_id}/shifts/{shift_id}/ — manage_events +# --------------------------------------------------------------------------- +@event_shifts_router.post("/{shift_id}/", response_model=TournamentShiftRead, status_code=status.HTTP_201_CREATED) +def attach_shift( + tournament_id: int, + event_id: int, + shift_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + event = get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event") + shift = get_scoped_or_404(db, TournamentShift, shift_id, tournament_id, "Shift") + + already_attached = ( + db.query(TournamentEventShift) + .filter( + TournamentEventShift.tournament_event_id == event.id, + TournamentEventShift.tournament_shift_id == shift.id, + ) + .first() + ) + if already_attached: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Shift already attached to this event") + + _validate_attach(event, shift, db) + + db.add(TournamentEventShift(tournament_event_id=event.id, tournament_shift_id=shift.id)) + db.commit() + db.refresh(shift) + return shift + + +# --------------------------------------------------------------------------- +# DELETE /tournaments/{tournament_id}/events/{event_id}/shifts/{shift_id}/ — manage_events +# --------------------------------------------------------------------------- +@event_shifts_router.delete("/{shift_id}/", status_code=status.HTTP_204_NO_CONTENT) +def detach_shift( + tournament_id: int, + event_id: int, + shift_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + event = get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event") + shift = get_scoped_or_404(db, TournamentShift, shift_id, tournament_id, "Shift") + + bridge = ( + db.query(TournamentEventShift) + .filter( + TournamentEventShift.tournament_event_id == event.id, + TournamentEventShift.tournament_shift_id == shift.id, + ) + .first() + ) + if not bridge: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shift not attached to this event") + + db.delete(bridge) + db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 98cd7aaf..b421ed51 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ ) from app.api.routes import tournament as tournament_core from app.api.routes.tournament import events as tournament_events +from app.api.routes.tournament import shifts as tournament_shifts from app.api.routes.tournament import memberships as tournament_memberships from app.api.routes.tournament import roles as tournament_roles from app.api.routes.tournament import join_codes as tournament_join_codes @@ -91,6 +92,8 @@ def _run_archive_job(): app.include_router(events.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_core.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_events.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(tournament_shifts.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(tournament_shifts.event_shifts_router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_memberships.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_roles.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_roles.membership_roles_router, prefix="", dependencies=[api_key_dependency]) diff --git a/backend/tests/api/tournament/test_shifts.py b/backend/tests/api/tournament/test_shifts.py new file mode 100644 index 00000000..5306c2a6 --- /dev/null +++ b/backend/tests/api/tournament/test_shifts.py @@ -0,0 +1,181 @@ +"""Tests for /tournaments/{tournament_id}/shifts endpoints (TournamentShift model) +and event/shift attach-detach.""" +from tests.conftest import grant_role, login + + +def _make_shift(client, tournament_id, **overrides): + payload = { + "label": "Shift 1", + "start": "2026-03-14T08:00:00Z", + "end": "2026-03-14T12:00:00Z", + } + payload.update(overrides) + return client.post(f"/tournaments/{tournament_id}/shifts/", json=payload) + + +def _make_event(client, tournament_id, **overrides): + payload = { + "tournament_id": tournament_id, + "name": "Boomilever", + "division": "C", + "start_time": "2026-03-14T08:00:00Z", + "end_time": "2026-03-14T16:00:00Z", + } + payload.update(overrides) + return client.post(f"/tournaments/{tournament_id}/events/", json=payload).json() + + +# --------------------------------------------------------------------------- +# Shift CRUD +# --------------------------------------------------------------------------- + +def test_create_shift(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_shift(client, td_tournament.id) + assert response.status_code == 201 + data = response.json() + assert data["label"] == "Shift 1" + assert data["tournament_id"] == td_tournament.id + + +def test_create_shift_end_before_start_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_shift(client, td_tournament.id, start="2026-03-14T12:00:00Z", end="2026-03-14T08:00:00Z") + assert response.status_code == 422 + + +def test_list_shifts(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + _make_shift(client, td_tournament.id, label="Shift 1") + _make_shift(client, td_tournament.id, label="Shift 2", start="2026-03-14T12:00:00Z", end="2026-03-14T16:00:00Z") + response = client.get(f"/tournaments/{td_tournament.id}/shifts/") + assert response.status_code == 200 + assert len(response.json()) == 2 + + +def test_update_shift(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_shift(client, td_tournament.id).json() + response = client.patch( + f"/tournaments/{td_tournament.id}/shifts/{created['id']}/", + json={"label": "Renamed Shift"}, + ) + assert response.status_code == 200 + assert response.json()["label"] == "Renamed Shift" + + +def test_delete_shift(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_shift(client, td_tournament.id).json() + assert client.delete(f"/tournaments/{td_tournament.id}/shifts/{created['id']}/").status_code == 204 + + +def test_delete_shift_attached_to_two_events_detaches_both(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + shift = _make_shift(client, td_tournament.id).json() + event1 = _make_event(client, td_tournament.id, name="Boomilever") + event2 = _make_event(client, td_tournament.id, name="Hovercraft") + + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event1['id']}/shifts/{shift['id']}/" + ).status_code == 201 + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event2['id']}/shifts/{shift['id']}/" + ).status_code == 201 + + assert client.delete(f"/tournaments/{td_tournament.id}/shifts/{shift['id']}/").status_code == 204 + + e1 = client.get(f"/tournaments/{td_tournament.id}/events/{event1['id']}/").json() + e2 = client.get(f"/tournaments/{td_tournament.id}/events/{event2['id']}/").json() + assert e1["shifts"] == [] + assert e2["shifts"] == [] + + +def test_shift_routes_require_manage_events(client, td_user, other_tournament, db): + grant_role(db, other_tournament, td_user, "Volunteer") + login(client, "td@test.com", "tdpass") + assert client.get(f"/tournaments/{other_tournament.id}/shifts/").status_code == 403 + + +# --------------------------------------------------------------------------- +# Attach / detach +# --------------------------------------------------------------------------- + +def test_attach_shift_success(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id) + shift = _make_shift(client, td_tournament.id).json() + response = client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift['id']}/" + ) + assert response.status_code == 201 + updated_event = client.get(f"/tournaments/{td_tournament.id}/events/{event['id']}/").json() + assert len(updated_event["shifts"]) == 1 + + +def test_attach_shift_outside_event_bounds_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id, start_time="2026-03-14T08:00:00Z", end_time="2026-03-14T10:00:00Z") + shift = _make_shift(client, td_tournament.id, start="2026-03-14T09:00:00Z", end="2026-03-14T11:00:00Z").json() + response = client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift['id']}/" + ) + assert response.status_code == 409 + + +def test_attach_overlapping_shift_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id, start_time="2026-03-14T08:00:00Z", end_time="2026-03-14T16:00:00Z") + shift1 = _make_shift(client, td_tournament.id, start="2026-03-14T08:00:00Z", end="2026-03-14T12:00:00Z").json() + shift2 = _make_shift(client, td_tournament.id, start="2026-03-14T11:00:00Z", end="2026-03-14T15:00:00Z").json() + + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift1['id']}/" + ).status_code == 201 + response = client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift2['id']}/" + ) + assert response.status_code == 409 + + +def test_attach_adjacent_shift_succeeds(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id, start_time="2026-03-14T08:00:00Z", end_time="2026-03-14T16:00:00Z") + shift1 = _make_shift(client, td_tournament.id, start="2026-03-14T08:00:00Z", end="2026-03-14T12:00:00Z").json() + shift2 = _make_shift(client, td_tournament.id, start="2026-03-14T12:00:00Z", end="2026-03-14T16:00:00Z").json() + + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift1['id']}/" + ).status_code == 201 + response = client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift2['id']}/" + ) + assert response.status_code == 201 + + +def test_same_shift_attached_to_multiple_events_succeeds(client, td_user, td_tournament): + """Reuse case — a shift isn't scoped to a single event.""" + login(client, "td@test.com", "tdpass") + shift = _make_shift(client, td_tournament.id).json() + event1 = _make_event(client, td_tournament.id, name="Boomilever") + event2 = _make_event(client, td_tournament.id, name="Hovercraft") + + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event1['id']}/shifts/{shift['id']}/" + ).status_code == 201 + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event2['id']}/shifts/{shift['id']}/" + ).status_code == 201 + + +def test_detach_shift(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id) + shift = _make_shift(client, td_tournament.id).json() + client.post(f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift['id']}/") + + response = client.delete(f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift['id']}/") + assert response.status_code == 204 + + updated_event = client.get(f"/tournaments/{td_tournament.id}/events/{event['id']}/").json() + assert updated_event["shifts"] == [] From 83a3252c64924c3656fdb2556fe9b3dbe3c234f7 Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 17:22:20 -0700 Subject: [PATCH 08/71] feat(event): add SeasonEvent admin routes and tournament events bulk-load action --- ...6b7c8_tournament_events_backend_rewrite.py | 44 ++---- backend/app/api/routes/season_event.py | 100 +++++++++++++ backend/app/api/routes/tournament/events.py | 73 +++++++++- backend/app/main.py | 3 +- backend/app/models/models.py | 8 +- backend/app/schemas/tournament/event.py | 32 +++-- backend/tests/api/test_season_events.py | 134 ++++++++++++++++++ backend/tests/api/tournament/test_events.py | 2 +- 8 files changed, 351 insertions(+), 45 deletions(-) create mode 100644 backend/app/api/routes/season_event.py create mode 100644 backend/tests/api/test_season_events.py diff --git a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py index a415f345..95cd9892 100644 --- a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py +++ b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py @@ -23,12 +23,14 @@ custom-event-only display text — catalog-linked events display the joined Event.name instead) - tournament_events: add `event_id` FK to events (SET NULL), nullable - - tournament_events: add `start_time`/`end_time`, backfilled from the - parent tournament's start_date/end_date (else now()) since existing - rows have no equivalent data + - tournament_events: add `start_time`/`end_time`, both nullable. No backfill for + existing rows; they simply stay NULL. Frontend is expected to warn on + unset times, not block on them. - tournament_events: drop `uq_tournament_event_division`, add a partial unique index on (tournament_id, event_id, division) WHERE event_id IS NOT NULL — custom (event_id-less) events have no uniqueness constraint + - tournament_events: `volunteers_needed` made nullable, default dropped + (no more implicit "2" — unset means genuinely unset, not a guess) - season_events: new table — admin-curated per-year/division active event list, drives the tournament events bulk-load default list. No @@ -79,8 +81,9 @@ def upgrade() -> None: # ------------------------------------------------------------------ # tournament_events — drop blocks/category, add event_id, add - # start_time/end_time (backfilled), relax division, swap the unique - # constraint for a partial unique index scoped to catalog-linked rows + # start_time/end_time (nullable, no backfill), relax division, swap + # the unique constraint for a partial unique index scoped to + # catalog-linked rows # ------------------------------------------------------------------ op.drop_constraint("uq_tournament_event_division", "tournament_events", type_="unique") op.alter_column("tournament_events", "division", existing_type=sa.String(length=4), nullable=True) @@ -93,33 +96,9 @@ def upgrade() -> None: "tournament_events", sa.Column("event_id", sa.Integer(), sa.ForeignKey("events.id", ondelete="SET NULL"), nullable=True), ) - - # start_time/end_time: add nullable first so the backfill can run, then - # tighten to NOT NULL once every row has a value. op.add_column("tournament_events", sa.Column("start_time", sa.DateTime(timezone=True), nullable=True)) op.add_column("tournament_events", sa.Column("end_time", sa.DateTime(timezone=True), nullable=True)) - conn = op.get_bind() - # TODO(temp): backfilled from the parent tournament's date range since - # old `blocks` was a scheduling mechanism, not a datetime range — TD - # should review/adjust these once the events UI ships. Flag in PR. - result = conn.execute( - text( - """ - UPDATE tournament_events te - SET start_time = COALESCE(t.start_date::timestamptz, now()), - end_time = COALESCE(t.end_date::timestamptz, now()) - FROM tournaments t - WHERE t.id = te.tournament_id - """ - ) - ) - print(f"[d3e4f5a6b7c8] backfilled start_time/end_time on {result.rowcount} tournament_events row(s) " - f"from parent tournament dates — needs TD review, see TODO(temp) in this migration") - - op.alter_column("tournament_events", "start_time", existing_type=sa.DateTime(timezone=True), nullable=False) - op.alter_column("tournament_events", "end_time", existing_type=sa.DateTime(timezone=True), nullable=False) - op.create_index( "uq_tournament_event_catalog_division", "tournament_events", @@ -128,6 +107,8 @@ def upgrade() -> None: postgresql_where=text("event_id IS NOT NULL"), ) + op.alter_column("tournament_events", "volunteers_needed", existing_type=sa.Integer(), nullable=True) + # ------------------------------------------------------------------ # season_events — new table # ------------------------------------------------------------------ @@ -156,6 +137,11 @@ def downgrade() -> None: # ------------------------------------------------------------------ op.drop_index("uq_tournament_event_catalog_division", table_name="tournament_events") + # Any rows with NULL volunteers_needed get 2 (the old default) so the + # NOT NULL constraint below doesn't fail. + op.execute("UPDATE tournament_events SET volunteers_needed = 2 WHERE volunteers_needed IS NULL") + op.alter_column("tournament_events", "volunteers_needed", existing_type=sa.Integer(), nullable=False) + op.drop_column("tournament_events", "end_time") op.drop_column("tournament_events", "start_time") op.drop_column("tournament_events", "event_id") diff --git a/backend/app/api/routes/season_event.py b/backend/app/api/routes/season_event.py new file mode 100644 index 00000000..8148e78f --- /dev/null +++ b/backend/app/api/routes/season_event.py @@ -0,0 +1,100 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, joinedload + +from app.core.auth import require_admin +from app.db.session import get_db +from app.models.models import SeasonEvent, User +from app.schemas.season_event import SeasonEventCreate, SeasonEventRead, SeasonEventUpdate + +# Namespaced under /admin/, matching app/api/routes/tournament/admin.py — +# platform-admin-only resource, kept under a single consistent prefix. +router = APIRouter(prefix="/admin/season-events", tags=["season-events"]) + + +# --------------------------------------------------------------------------- +# GET /admin/season-events/ — admin only, filterable by year/division +# --------------------------------------------------------------------------- +@router.get("/", response_model=list[SeasonEventRead]) +def list_season_events( + year: int | None = Query(None), + division: str | None = Query(None), + db: Session = Depends(get_db), + _: User = Depends(require_admin), +): + query = db.query(SeasonEvent).options(joinedload(SeasonEvent.event)) + if year is not None: + query = query.filter(SeasonEvent.year == year) + if division is not None: + query = query.filter(SeasonEvent.division == division) + return query.order_by(SeasonEvent.year.desc(), SeasonEvent.division).all() + + +# --------------------------------------------------------------------------- +# POST /admin/season-events/ — admin only +# --------------------------------------------------------------------------- +@router.post("/", response_model=SeasonEventRead, status_code=status.HTTP_201_CREATED) +def create_season_event( + payload: SeasonEventCreate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +): + season_event = SeasonEvent(**payload.model_dump()) + db.add(season_event) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A season event for this event/year/division already exists", + ) + db.refresh(season_event) + return season_event + + +# --------------------------------------------------------------------------- +# PATCH /admin/season-events/{id}/ — admin only, primarily used to toggle is_active +# --------------------------------------------------------------------------- +@router.patch("/{season_event_id}/", response_model=SeasonEventRead) +def update_season_event( + season_event_id: int, + payload: SeasonEventUpdate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +): + season_event = db.get(SeasonEvent, season_event_id) + if not season_event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Season event not found") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(season_event, field, value) + + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A season event for this event/year/division already exists", + ) + db.refresh(season_event) + return season_event + + +# --------------------------------------------------------------------------- +# DELETE /admin/season-events/{id}/ — admin only +# --------------------------------------------------------------------------- +@router.delete("/{season_event_id}/", status_code=status.HTTP_204_NO_CONTENT) +def delete_season_event( + season_event_id: int, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +): + season_event = db.get(SeasonEvent, season_event_id) + if not season_event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Season event not found") + + db.delete(season_event) + db.commit() diff --git a/backend/app/api/routes/tournament/events.py b/backend/app/api/routes/tournament/events.py index 5e89fce6..06ff1b42 100644 --- a/backend/app/api/routes/tournament/events.py +++ b/backend/app/api/routes/tournament/events.py @@ -6,8 +6,10 @@ from app.core.tournament import get_scoped_or_404, get_tournament, require_not_archived from app.core.tournament.permissions import MANAGE_EVENTS, require_permission from app.db.session import get_db -from app.models.models import TournamentEvent, User -from app.schemas.tournament.event import EventCreate, EventRead, EventUpdate +from app.models.models import SeasonEvent, TournamentEvent, User +from app.schemas.tournament.event import ( + EventCreate, EventLoadDefaultsResponse, EventLoadDefaultsSkipped, EventRead, EventUpdate, +) # Routes are nested: /tournaments/{tournament_id}/events/... # tournament_id is always present in the path, which drives the permission check. @@ -153,3 +155,70 @@ def delete_event( event = get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event") db.delete(event) db.commit() + + +# --------------------------------------------------------------------------- +# POST /tournaments/{tournament_id}/events/load-defaults/ — manage_events +# Bulk-creates TournamentEvent rows from every active SeasonEvent whose +# division the tournament supports. Snapshots name from Event.name at load +# time — not a live link, the TD can rename after. Skips anything that +# would violate the (tournament_id, event_id, division) uniqueness +# constraint (i.e. already loaded) rather than erroring the whole batch. +# start_time/end_time are left unset — see TournamentEvent.start_time. +# --------------------------------------------------------------------------- +@router.post("/load-defaults/", response_model=EventLoadDefaultsResponse, status_code=status.HTTP_201_CREATED) +def load_default_events( + tournament_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + active_season_events = ( + db.query(SeasonEvent) + .options(joinedload(SeasonEvent.event)) + .filter( + SeasonEvent.is_active.is_(True), + SeasonEvent.division.in_(tournament.division or []), + ) + .all() + ) + + existing_pairs = { + (te.event_id, te.division) + for te in db.query(TournamentEvent).filter( + TournamentEvent.tournament_id == tournament_id, + TournamentEvent.event_id.isnot(None), + ) + } + + created: list[TournamentEvent] = [] + skipped: list[EventLoadDefaultsSkipped] = [] + seen_pairs: set[tuple[int, str]] = set() + + for season_event in active_season_events: + key = (season_event.event_id, season_event.division) + if key in existing_pairs or key in seen_pairs: + skipped.append(EventLoadDefaultsSkipped( + event_id=season_event.event_id, + division=season_event.division, + name=season_event.event.name, + )) + continue + seen_pairs.add(key) + + event = TournamentEvent( + tournament_id=tournament_id, + event_id=season_event.event_id, + division=season_event.division, + name=season_event.event.name, + ) + db.add(event) + created.append(event) + + db.commit() + for event in created: + db.refresh(event) + + return EventLoadDefaultsResponse(created=created, skipped=skipped) diff --git a/backend/app/main.py b/backend/app/main.py index b421ed51..7cf65775 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from app.core.security import verify_api_key from app.db.init_db import init_db, seed_dev_data from app.api.routes import ( - auth, events, join, + auth, events, join, season_event, sheets, users, user_experience, universities, ) from app.api.routes import tournament as tournament_core @@ -90,6 +90,7 @@ def _run_archive_job(): app.include_router(auth.router, prefix="", dependencies=[api_key_dependency]) app.include_router(join.router, prefix="", dependencies=[api_key_dependency]) app.include_router(events.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(season_event.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_core.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_events.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_shifts.router, prefix="", dependencies=[api_key_dependency]) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index e44c69f0..8a055e95 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -483,10 +483,12 @@ class TournamentEvent(Base): room = Column(String(64), nullable=True) floor = Column(String(64), nullable=True) - volunteers_needed = Column(Integer, nullable=False, default=2) + volunteers_needed = Column(Integer, nullable=True) - start_time = Column(DateTime(timezone=True), nullable=False) - end_time = Column(DateTime(timezone=True), nullable=False) + # Nullable — tournament planning starts before per-event times are known. + # Frontend is expected to warn on unset times, not block on them here. + start_time = Column(DateTime(timezone=True), nullable=True) + end_time = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), default=utcnow) updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) diff --git a/backend/app/schemas/tournament/event.py b/backend/app/schemas/tournament/event.py index e4f39049..274f6e10 100644 --- a/backend/app/schemas/tournament/event.py +++ b/backend/app/schemas/tournament/event.py @@ -19,9 +19,11 @@ class EventBase(BaseModel): building: str | None = None room: str | None = None floor: str | None = None - volunteers_needed: int = 2 - start_time: datetime - end_time: datetime + volunteers_needed: int | None = None + # Nullable — a tournament's event schedule isn't known at planning + # time. Frontend is expected to warn on unset times, not block on them. + start_time: datetime | None = None + end_time: datetime | None = None @field_validator("division") @classmethod @@ -39,14 +41,14 @@ def validate_event_type(cls, v: str) -> str: @field_validator("volunteers_needed") @classmethod - def validate_volunteers_needed(cls, v: int) -> int: - if v < 1: + def validate_volunteers_needed(cls, v: int | None) -> int | None: + if v is not None and v < 1: raise ValueError("volunteers_needed must be at least 1") return v @model_validator(mode="after") def validate_times(self) -> "EventBase": - if self.end_time <= self.start_time: + if self.start_time is not None and self.end_time is not None and self.end_time <= self.start_time: raise ValueError("end_time must be after start_time") return self @@ -117,11 +119,23 @@ class EventRead(BaseModel): building: str | None = None room: str | None = None floor: str | None = None - volunteers_needed: int - start_time: datetime - end_time: datetime + volunteers_needed: int | None = None + start_time: datetime | None = None + end_time: datetime | None = None shifts: list[TournamentShiftRead] = [] created_at: datetime updated_at: datetime model_config = {"from_attributes": True} + + +class EventLoadDefaultsSkipped(BaseModel): + event_id: int + division: str + name: str + reason: str = "already loaded" + + +class EventLoadDefaultsResponse(BaseModel): + created: list[EventRead] + skipped: list[EventLoadDefaultsSkipped] diff --git a/backend/tests/api/test_season_events.py b/backend/tests/api/test_season_events.py new file mode 100644 index 00000000..724e2e15 --- /dev/null +++ b/backend/tests/api/test_season_events.py @@ -0,0 +1,134 @@ +"""Tests for /admin/season-events endpoints (SeasonEvent model) and the +/tournaments/{tournament_id}/events/load-defaults bulk-load action.""" +from tests.conftest import login +from app.models.models import SeasonEvent + + +def _login_admin(client): + login(client, "admin@test.com", "adminpass") + + +def _make_season_event(client, **overrides): + payload = {"year": 2026, "division": "C", "is_active": True} + payload.update(overrides) + return client.post("/admin/season-events/", json=payload) + + +# --------------------------------------------------------------------------- +# Admin CRUD +# --------------------------------------------------------------------------- + +def test_create_season_event(client, admin_user, event): + _login_admin(client) + response = _make_season_event(client, event_id=event.id) + assert response.status_code == 201 + data = response.json() + assert data["event_id"] == event.id + assert data["year"] == 2026 + assert data["division"] == "C" + assert data["is_active"] is True + assert data["event"]["name"] == event.name + + +def test_create_season_event_duplicate_rejected(client, admin_user, event): + _login_admin(client) + assert _make_season_event(client, event_id=event.id).status_code == 201 + assert _make_season_event(client, event_id=event.id).status_code == 409 + + +def test_list_season_events_filter_by_year_division(client, admin_user, event_factory, event_category): + _login_admin(client) + e1 = event_factory(event_category, name="Boomilever") + e2 = event_factory(event_category, name="Hovercraft") + _make_season_event(client, event_id=e1.id, year=2026, division="C") + _make_season_event(client, event_id=e2.id, year=2027, division="B") + + response = client.get("/admin/season-events/", params={"year": 2026}) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["event_id"] == e1.id + + +def test_update_season_event_toggle_active(client, admin_user, event): + _login_admin(client) + created = _make_season_event(client, event_id=event.id, is_active=False).json() + response = client.patch(f"/admin/season-events/{created['id']}/", json={"is_active": True}) + assert response.status_code == 200 + assert response.json()["is_active"] is True + + +def test_delete_season_event(client, admin_user, event): + _login_admin(client) + created = _make_season_event(client, event_id=event.id).json() + assert client.delete(f"/admin/season-events/{created['id']}/").status_code == 204 + + +def test_season_event_routes_require_admin(client, td_user, event): + """A regular (non-admin) user gets 403 on every admin route.""" + login(client, "td@test.com", "tdpass") + assert _make_season_event(client, event_id=event.id).status_code == 403 + assert client.get("/admin/season-events/").status_code == 403 + + +# --------------------------------------------------------------------------- +# Bulk-load +# --------------------------------------------------------------------------- + +def test_load_defaults_only_pulls_supported_divisions(client, admin_user, td_user, td_tournament, db, event_factory, event_category): + """td_tournament supports divisions B/C only (see conftest).""" + e_c = event_factory(event_category, name="Boomilever") + e_a = event_factory(event_category, name="Elementary Event") + db.add_all([ + SeasonEvent(event_id=e_c.id, year=2026, division="C", is_active=True), + SeasonEvent(event_id=e_a.id, year=2026, division="A", is_active=True), + ]) + db.commit() + + login(client, "td@test.com", "tdpass") + response = client.post(f"/tournaments/{td_tournament.id}/events/load-defaults/") + assert response.status_code == 201 + data = response.json() + assert len(data["created"]) == 1 + assert data["created"][0]["event_id"] == e_c.id + assert data["skipped"] == [] + + +def test_load_defaults_skips_already_loaded(client, admin_user, td_user, td_tournament, db, event_factory, event_category): + e_c = event_factory(event_category, name="Boomilever") + db.add(SeasonEvent(event_id=e_c.id, year=2026, division="C", is_active=True)) + db.commit() + + login(client, "td@test.com", "tdpass") + first = client.post(f"/tournaments/{td_tournament.id}/events/load-defaults/") + assert len(first.json()["created"]) == 1 + + second = client.post(f"/tournaments/{td_tournament.id}/events/load-defaults/") + assert second.status_code == 201 + assert second.json()["created"] == [] + assert len(second.json()["skipped"]) == 1 + assert second.json()["skipped"][0]["event_id"] == e_c.id + + +def test_load_defaults_no_active_season_events_is_noop(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = client.post(f"/tournaments/{td_tournament.id}/events/load-defaults/") + assert response.status_code == 201 + assert response.json() == {"created": [], "skipped": []} + + +def test_load_defaults_ignores_inactive_season_events(client, td_user, td_tournament, db, event_factory, event_category): + e_c = event_factory(event_category, name="Boomilever") + db.add(SeasonEvent(event_id=e_c.id, year=2026, division="C", is_active=False)) + db.commit() + + login(client, "td@test.com", "tdpass") + response = client.post(f"/tournaments/{td_tournament.id}/events/load-defaults/") + assert response.status_code == 201 + assert response.json() == {"created": [], "skipped": []} + + +def test_load_defaults_requires_manage_events(client, td_user, other_tournament, db): + from tests.conftest import grant_role + grant_role(db, other_tournament, td_user, "Volunteer") + login(client, "td@test.com", "tdpass") + assert client.post(f"/tournaments/{other_tournament.id}/events/load-defaults/").status_code == 403 diff --git a/backend/tests/api/tournament/test_events.py b/backend/tests/api/tournament/test_events.py index 134adccf..5d275555 100644 --- a/backend/tests/api/tournament/test_events.py +++ b/backend/tests/api/tournament/test_events.py @@ -29,7 +29,7 @@ def test_create_event_minimal(client, td_user, td_tournament): assert data["division"] == "C" assert data["tournament_id"] == td_tournament.id assert data["event_type"] == "standard" - assert data["volunteers_needed"] == 2 + assert data["volunteers_needed"] is None def test_create_event_full(client, td_user, td_tournament): From 3f19e4a99117a8ac52972574e264ed759af830f6 Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 17:31:31 -0700 Subject: [PATCH 09/71] refactor(event): split canonical event and season-event admin writes under /admin/, keep GET routes public --- backend/app/api/routes/events.py | 31 +++++++++------- backend/app/api/routes/season_event.py | 16 ++++----- backend/app/main.py | 2 ++ backend/tests/api/test_events.py | 48 ++++++++++++------------- backend/tests/api/test_season_events.py | 13 ++++--- 5 files changed, 61 insertions(+), 49 deletions(-) diff --git a/backend/app/api/routes/events.py b/backend/app/api/routes/events.py index b567e581..4b0a2474 100644 --- a/backend/app/api/routes/events.py +++ b/backend/app/api/routes/events.py @@ -12,6 +12,11 @@ router = APIRouter(tags=["events"]) +# Admin-only writes live under /admin/, matching app/api/routes/tournament/admin.py +# and app/api/routes/season_event.py — the public GET routes above stay +# unprefixed and unauthenticated. +admin_router = APIRouter(prefix="/admin", tags=["events"]) + # --------------------------------------------------------------------------- # GET /events/ — list all events @@ -22,9 +27,9 @@ def list_events(db: Session = Depends(get_db)): # --------------------------------------------------------------------------- -# POST /events/ — admin only, create a new event +# POST /admin/events/ — admin only, create a new event # --------------------------------------------------------------------------- -@router.post("/events/", response_model=EventResponse, status_code=status.HTTP_201_CREATED) +@admin_router.post("/events/", response_model=EventResponse, status_code=status.HTTP_201_CREATED) def create_event(body: EventCreate, db: Session = Depends(get_db), _: User = Depends(require_admin)): category = db.get(EventCategory, body.category_id) if not category: @@ -38,9 +43,9 @@ def create_event(body: EventCreate, db: Session = Depends(get_db), _: User = Dep # --------------------------------------------------------------------------- -# PATCH /events/{id}/ — admin only, partial update +# PATCH /admin/events/{id}/ — admin only, partial update # --------------------------------------------------------------------------- -@router.patch("/events/{event_id}/", response_model=EventResponse) +@admin_router.patch("/events/{event_id}/", response_model=EventResponse) def update_event(event_id: int, body: EventUpdate, db: Session = Depends(get_db), _: User = Depends(require_admin)): event = db.get(Event, event_id) if not event: @@ -62,9 +67,9 @@ def update_event(event_id: int, body: EventUpdate, db: Session = Depends(get_db) # --------------------------------------------------------------------------- -# DELETE /events/{id}/ — admin only, hard delete blocked if experience entries exist +# DELETE /admin/events/{id}/ — admin only, hard delete blocked if experience entries exist # --------------------------------------------------------------------------- -@router.delete("/events/{event_id}/", status_code=status.HTTP_204_NO_CONTENT) +@admin_router.delete("/events/{event_id}/", status_code=status.HTTP_204_NO_CONTENT) def delete_event(event_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin)): event = db.get(Event, event_id) if not event: @@ -90,9 +95,9 @@ def list_event_categories(db: Session = Depends(get_db)): # --------------------------------------------------------------------------- -# POST /event-categories/ — admin only, create a new category +# POST /admin/event-categories/ — admin only, create a new category # --------------------------------------------------------------------------- -@router.post("/event-categories/", response_model=EventCategoryResponse, status_code=status.HTTP_201_CREATED) +@admin_router.post("/event-categories/", response_model=EventCategoryResponse, status_code=status.HTTP_201_CREATED) def create_event_category(body: EventCategoryCreate, db: Session = Depends(get_db), _: User = Depends(require_admin)): category = EventCategory(name=body.name) db.add(category) @@ -102,9 +107,9 @@ def create_event_category(body: EventCategoryCreate, db: Session = Depends(get_d # --------------------------------------------------------------------------- -# PATCH /event-categories/{id}/ — admin only, partial update +# PATCH /admin/event-categories/{id}/ — admin only, partial update # --------------------------------------------------------------------------- -@router.patch("/event-categories/{category_id}/", response_model=EventCategoryResponse) +@admin_router.patch("/event-categories/{category_id}/", response_model=EventCategoryResponse) def update_event_category( category_id: int, body: EventCategoryUpdate, @@ -124,10 +129,10 @@ def update_event_category( # --------------------------------------------------------------------------- -# DELETE /event-categories/{id}/ — admin only, cascades to delete its events +# DELETE /admin/event-categories/{id}/ — admin only, cascades to delete its events # (blocked if any of those events has experience entries — see delete_event note) # --------------------------------------------------------------------------- -@router.delete("/event-categories/{category_id}/", status_code=status.HTTP_204_NO_CONTENT) +@admin_router.delete("/event-categories/{category_id}/", status_code=status.HTTP_204_NO_CONTENT) def delete_event_category(category_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin)): category = db.get(EventCategory, category_id) if not category: @@ -141,4 +146,4 @@ def delete_event_category(category_id: int, db: Session = Depends(get_db), _: Us raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Cannot delete category: one or more of its events has associated experience entries", - ) \ No newline at end of file + ) diff --git a/backend/app/api/routes/season_event.py b/backend/app/api/routes/season_event.py index 8148e78f..3bc1d203 100644 --- a/backend/app/api/routes/season_event.py +++ b/backend/app/api/routes/season_event.py @@ -8,20 +8,20 @@ from app.models.models import SeasonEvent, User from app.schemas.season_event import SeasonEventCreate, SeasonEventRead, SeasonEventUpdate -# Namespaced under /admin/, matching app/api/routes/tournament/admin.py — -# platform-admin-only resource, kept under a single consistent prefix. -router = APIRouter(prefix="/admin/season-events", tags=["season-events"]) +# GET is public/unauthenticated, matching the canonical Event/EventCategory +# split (app/api/routes/events.py) — writes are admin-only, under /admin/. +router = APIRouter(prefix="/season-events", tags=["season-events"]) +admin_router = APIRouter(prefix="/admin/season-events", tags=["season-events"]) # --------------------------------------------------------------------------- -# GET /admin/season-events/ — admin only, filterable by year/division +# GET /season-events/ — filterable by year/division # --------------------------------------------------------------------------- @router.get("/", response_model=list[SeasonEventRead]) def list_season_events( year: int | None = Query(None), division: str | None = Query(None), db: Session = Depends(get_db), - _: User = Depends(require_admin), ): query = db.query(SeasonEvent).options(joinedload(SeasonEvent.event)) if year is not None: @@ -34,7 +34,7 @@ def list_season_events( # --------------------------------------------------------------------------- # POST /admin/season-events/ — admin only # --------------------------------------------------------------------------- -@router.post("/", response_model=SeasonEventRead, status_code=status.HTTP_201_CREATED) +@admin_router.post("/", response_model=SeasonEventRead, status_code=status.HTTP_201_CREATED) def create_season_event( payload: SeasonEventCreate, db: Session = Depends(get_db), @@ -57,7 +57,7 @@ def create_season_event( # --------------------------------------------------------------------------- # PATCH /admin/season-events/{id}/ — admin only, primarily used to toggle is_active # --------------------------------------------------------------------------- -@router.patch("/{season_event_id}/", response_model=SeasonEventRead) +@admin_router.patch("/{season_event_id}/", response_model=SeasonEventRead) def update_season_event( season_event_id: int, payload: SeasonEventUpdate, @@ -86,7 +86,7 @@ def update_season_event( # --------------------------------------------------------------------------- # DELETE /admin/season-events/{id}/ — admin only # --------------------------------------------------------------------------- -@router.delete("/{season_event_id}/", status_code=status.HTTP_204_NO_CONTENT) +@admin_router.delete("/{season_event_id}/", status_code=status.HTTP_204_NO_CONTENT) def delete_season_event( season_event_id: int, db: Session = Depends(get_db), diff --git a/backend/app/main.py b/backend/app/main.py index 7cf65775..e645f541 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -90,7 +90,9 @@ def _run_archive_job(): app.include_router(auth.router, prefix="", dependencies=[api_key_dependency]) app.include_router(join.router, prefix="", dependencies=[api_key_dependency]) app.include_router(events.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(events.admin_router, prefix="", dependencies=[api_key_dependency]) app.include_router(season_event.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(season_event.admin_router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_core.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_events.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_shifts.router, prefix="", dependencies=[api_key_dependency]) diff --git a/backend/tests/api/test_events.py b/backend/tests/api/test_events.py index 5fd1265e..b6c7eec9 100644 --- a/backend/tests/api/test_events.py +++ b/backend/tests/api/test_events.py @@ -28,7 +28,7 @@ def test_list_events_anonymous_allowed(self, client, event_category_factory, eve class TestCreateEvent: def test_admin_can_create_event(self, client, admin_user, event_category): login(client, "admin@test.com", "adminpass") - res = client.post("/events/", json={"name": "Boomilever", "category_id": event_category.id}) + res = client.post("/admin/events/", json={"name": "Boomilever", "category_id": event_category.id}) assert res.status_code == 201 data = res.json() assert data["name"] == "Boomilever" @@ -36,16 +36,16 @@ def test_admin_can_create_event(self, client, admin_user, event_category): def test_non_admin_forbidden(self, client, td_user, event_category): login(client, "td@test.com", "tdpass") - res = client.post("/events/", json={"name": "Boomilever", "category_id": event_category.id}) + res = client.post("/admin/events/", json={"name": "Boomilever", "category_id": event_category.id}) assert res.status_code == 403 def test_unauthenticated_forbidden(self, client, event_category): - res = client.post("/events/", json={"name": "Boomilever", "category_id": event_category.id}) + res = client.post("/admin/events/", json={"name": "Boomilever", "category_id": event_category.id}) assert res.status_code == 401 def test_invalid_category_id_404(self, client, admin_user): login(client, "admin@test.com", "adminpass") - res = client.post("/events/", json={"name": "Boomilever", "category_id": 9999}) + res = client.post("/admin/events/", json={"name": "Boomilever", "category_id": 9999}) assert res.status_code == 404 @@ -56,7 +56,7 @@ def test_invalid_category_id_404(self, client, admin_user): class TestUpdateEvent: def test_partial_update(self, client, admin_user, event): login(client, "admin@test.com", "adminpass") - res = client.patch(f"/events/{event.id}/", json={"name": "Hovercraft"}) + res = client.patch(f"/admin/events/{event.id}/", json={"name": "Hovercraft"}) assert res.status_code == 200 assert res.json()["name"] == "Hovercraft" @@ -65,22 +65,22 @@ def test_update_category_id(self, client, admin_user, event_category_factory, ev other_category = event_category_factory("Physics") event = event_factory(category) login(client, "admin@test.com", "adminpass") - res = client.patch(f"/events/{event.id}/", json={"category_id": other_category.id}) + res = client.patch(f"/admin/events/{event.id}/", json={"category_id": other_category.id}) assert res.status_code == 200 assert res.json()["category"]["id"] == other_category.id def test_missing_event_404(self, client, admin_user): login(client, "admin@test.com", "adminpass") - assert client.patch("/events/9999/", json={"name": "Hovercraft"}).status_code == 404 + assert client.patch("/admin/events/9999/", json={"name": "Hovercraft"}).status_code == 404 def test_invalid_category_id_404(self, client, admin_user, event): login(client, "admin@test.com", "adminpass") - res = client.patch(f"/events/{event.id}/", json={"category_id": 9999}) + res = client.patch(f"/admin/events/{event.id}/", json={"category_id": 9999}) assert res.status_code == 404 def test_non_admin_forbidden(self, client, td_user, event): login(client, "td@test.com", "tdpass") - assert client.patch(f"/events/{event.id}/", json={"name": "Hovercraft"}).status_code == 403 + assert client.patch(f"/admin/events/{event.id}/", json={"name": "Hovercraft"}).status_code == 403 # --------------------------------------------------------------------------- @@ -90,14 +90,14 @@ def test_non_admin_forbidden(self, client, td_user, event): class TestDeleteEvent: def test_delete_with_no_experience_entries(self, client, db, admin_user, event): login(client, "admin@test.com", "adminpass") - assert client.delete(f"/events/{event.id}/").status_code == 204 + assert client.delete(f"/admin/events/{event.id}/").status_code == 204 assert db.get(Event, event.id) is None def test_delete_blocked_by_competition_experience(self, client, db, admin_user, td_user, event): db.add(UserCompetitionExperience(user_id=td_user.id, event_id=event.id, school="MIT")) db.commit() login(client, "admin@test.com", "adminpass") - res = client.delete(f"/events/{event.id}/") + res = client.delete(f"/admin/events/{event.id}/") assert res.status_code == 409 assert db.get(Event, event.id) is not None @@ -108,17 +108,17 @@ def test_delete_blocked_by_volunteer_experience(self, client, db, admin_user, td )) db.commit() login(client, "admin@test.com", "adminpass") - res = client.delete(f"/events/{event.id}/") + res = client.delete(f"/admin/events/{event.id}/") assert res.status_code == 409 assert db.get(Event, event.id) is not None def test_delete_not_found(self, client, admin_user): login(client, "admin@test.com", "adminpass") - assert client.delete("/events/9999/").status_code == 404 + assert client.delete("/admin/events/9999/").status_code == 404 def test_non_admin_forbidden(self, client, td_user, event): login(client, "td@test.com", "tdpass") - assert client.delete(f"/events/{event.id}/").status_code == 403 + assert client.delete(f"/admin/events/{event.id}/").status_code == 403 # --------------------------------------------------------------------------- @@ -139,16 +139,16 @@ def test_list_categories(self, client, event_category): class TestCreateEventCategory: def test_admin_can_create_category(self, client, admin_user): login(client, "admin@test.com", "adminpass") - res = client.post("/event-categories/", json={"name": "Chemistry"}) + res = client.post("/admin/event-categories/", json={"name": "Chemistry"}) assert res.status_code == 201 assert res.json()["name"] == "Chemistry" def test_non_admin_forbidden(self, client, td_user): login(client, "td@test.com", "tdpass") - assert client.post("/event-categories/", json={"name": "Chemistry"}).status_code == 403 + assert client.post("/admin/event-categories/", json={"name": "Chemistry"}).status_code == 403 def test_unauthenticated_forbidden(self, client): - assert client.post("/event-categories/", json={"name": "Chemistry"}).status_code == 401 + assert client.post("/admin/event-categories/", json={"name": "Chemistry"}).status_code == 401 # --------------------------------------------------------------------------- @@ -158,17 +158,17 @@ def test_unauthenticated_forbidden(self, client): class TestUpdateEventCategory: def test_partial_update(self, client, admin_user, event_category): login(client, "admin@test.com", "adminpass") - res = client.patch(f"/event-categories/{event_category.id}/", json={"name": "Physics"}) + res = client.patch(f"/admin/event-categories/{event_category.id}/", json={"name": "Physics"}) assert res.status_code == 200 assert res.json()["name"] == "Physics" def test_missing_category_404(self, client, admin_user): login(client, "admin@test.com", "adminpass") - assert client.patch("/event-categories/9999/", json={"name": "Physics"}).status_code == 404 + assert client.patch("/admin/event-categories/9999/", json={"name": "Physics"}).status_code == 404 def test_non_admin_forbidden(self, client, td_user, event_category): login(client, "td@test.com", "tdpass") - res = client.patch(f"/event-categories/{event_category.id}/", json={"name": "Physics"}) + res = client.patch(f"/admin/event-categories/{event_category.id}/", json={"name": "Physics"}) assert res.status_code == 403 @@ -179,7 +179,7 @@ def test_non_admin_forbidden(self, client, td_user, event_category): class TestDeleteEventCategory: def test_delete_cascades_to_events(self, client, db, admin_user, event_category, event): login(client, "admin@test.com", "adminpass") - assert client.delete(f"/event-categories/{event_category.id}/").status_code == 204 + assert client.delete(f"/admin/event-categories/{event_category.id}/").status_code == 204 assert db.get(EventCategory, event_category.id) is None assert db.get(Event, event.id) is None @@ -189,7 +189,7 @@ def test_delete_blocked_when_event_has_experience_entries( db.add(UserCompetitionExperience(user_id=td_user.id, event_id=event.id, school="MIT")) db.commit() login(client, "admin@test.com", "adminpass") - res = client.delete(f"/event-categories/{event_category.id}/") + res = client.delete(f"/admin/event-categories/{event_category.id}/") assert res.status_code == 409 # Rollback — category and its event should still exist. assert db.get(EventCategory, event_category.id) is not None @@ -197,8 +197,8 @@ def test_delete_blocked_when_event_has_experience_entries( def test_delete_not_found(self, client, admin_user): login(client, "admin@test.com", "adminpass") - assert client.delete("/event-categories/9999/").status_code == 404 + assert client.delete("/admin/event-categories/9999/").status_code == 404 def test_non_admin_forbidden(self, client, td_user, event_category): login(client, "td@test.com", "tdpass") - assert client.delete(f"/event-categories/{event_category.id}/").status_code == 403 + assert client.delete(f"/admin/event-categories/{event_category.id}/").status_code == 403 diff --git a/backend/tests/api/test_season_events.py b/backend/tests/api/test_season_events.py index 724e2e15..b72acc4b 100644 --- a/backend/tests/api/test_season_events.py +++ b/backend/tests/api/test_season_events.py @@ -43,7 +43,7 @@ def test_list_season_events_filter_by_year_division(client, admin_user, event_fa _make_season_event(client, event_id=e1.id, year=2026, division="C") _make_season_event(client, event_id=e2.id, year=2027, division="B") - response = client.get("/admin/season-events/", params={"year": 2026}) + response = client.get("/season-events/", params={"year": 2026}) assert response.status_code == 200 assert len(response.json()) == 1 assert response.json()[0]["event_id"] == e1.id @@ -63,11 +63,16 @@ def test_delete_season_event(client, admin_user, event): assert client.delete(f"/admin/season-events/{created['id']}/").status_code == 204 -def test_season_event_routes_require_admin(client, td_user, event): - """A regular (non-admin) user gets 403 on every admin route.""" +def test_season_event_write_routes_require_admin(client, td_user, event): + """A regular (non-admin) user gets 403 on every admin write route.""" login(client, "td@test.com", "tdpass") assert _make_season_event(client, event_id=event.id).status_code == 403 - assert client.get("/admin/season-events/").status_code == 403 + + +def test_list_season_events_is_public(client, event): + """GET isn't admin-gated — anyone can read the season event list.""" + response = client.get("/season-events/") + assert response.status_code == 200 # --------------------------------------------------------------------------- From 8217fab8d033da1618b346fea856e3366ddd84ae Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 18:21:20 -0700 Subject: [PATCH 10/71] =?UTF-8?q?feat(frontend):=20update=20api.ts=20for?= =?UTF-8?q?=20tournament=20events=20backend=20rewrite=20=E2=80=94=20catalo?= =?UTF-8?q?g=20links,=20shifts,=20season=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/api.ts | 150 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 128 insertions(+), 22 deletions(-) diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 79248bb4..4d4e856a 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -78,19 +78,20 @@ export interface CanonicalEvent { category: EventCategory } +// Writes are admin-only, under /admin/ — GET stays public/unauthenticated. export const eventCategoriesApi = { list: () => api.get('/event-categories/'), - create: (body: { name: string }) => api.post('/event-categories/', body), - update: (id: number, body: { name: string }) => api.patch(`/event-categories/${id}/`, body), - delete: (id: number) => api.delete(`/event-categories/${id}/`), + create: (body: { name: string }) => api.post('/admin/event-categories/', body), + update: (id: number, body: { name: string }) => api.patch(`/admin/event-categories/${id}/`, body), + delete: (id: number) => api.delete(`/admin/event-categories/${id}/`), } export const canonicalEventsApi = { list: () => api.get('/events/'), - create: (body: { name: string; category_id: number }) => api.post('/events/', body), + create: (body: { name: string; category_id: number }) => api.post('/admin/events/', body), update: (id: number, body: Partial<{ name: string; category_id: number }>) => - api.patch(`/events/${id}/`, body), - delete: (id: number) => api.delete(`/events/${id}/`), + api.patch(`/admin/events/${id}/`, body), + delete: (id: number) => api.delete(`/admin/events/${id}/`), } // ------------------------------------------------------------------------- @@ -435,36 +436,149 @@ export const adminTournamentsApi = { } +// ------------------------------------------------------------------------- +// Tournament Shifts — nested under /tournaments/{id}/shifts/, and attached +// to events via /tournaments/{id}/events/{eventId}/shifts/{shiftId}/ +// ------------------------------------------------------------------------- +export interface TournamentShift { + id: number + tournament_id: number + label: string + start: string + end: string + created_at: string + updated_at: string +} + +export interface TournamentShiftInput { + label: string + start: string + end: string +} + +export const tournamentShiftsApi = { + list: (tournamentId: number) => + api.get(`/tournaments/${tournamentId}/shifts/`), + create: (tournamentId: number, body: TournamentShiftInput) => + api.post(`/tournaments/${tournamentId}/shifts/`, body), + update: (tournamentId: number, id: number, body: Partial) => + api.patch(`/tournaments/${tournamentId}/shifts/${id}/`, body), + // Cascades — detaches from any events it was attached to, no confirmation guard. + delete: (tournamentId: number, id: number) => + api.delete(`/tournaments/${tournamentId}/shifts/${id}/`), + // 409 on either bounds violation (outside the event's start/end) or + // overlap with another shift already attached to the same event. + attach: (tournamentId: number, eventId: number, shiftId: number) => + api.post(`/tournaments/${tournamentId}/events/${eventId}/shifts/${shiftId}/`, {}), + detach: (tournamentId: number, eventId: number, shiftId: number) => + api.delete(`/tournaments/${tournamentId}/events/${eventId}/shifts/${shiftId}/`), +} + // ------------------------------------------------------------------------- // Tournament Events — nested under /tournaments/{id}/events/ // ------------------------------------------------------------------------- +// name is set only for custom (event_id-less) events — a catalog-linked +// event's display name comes from the joined `event` field instead. export interface TournamentEvent { id: number tournament_id: number - name: string - division: 'B' | 'C' + name: string | null + division: TournamentDivision | null event_type: 'standard' | 'trial' - category: string | null + event_id: number | null + // Joined canonical event — set only when event_id is set. Carries + // category, since TournamentEvent has no category field of its own. + event: CanonicalEvent | null building: string | null room: string | null floor: string | null - volunteers_needed: number - blocks: number[] + volunteers_needed: number | null + // Nullable — a tournament's event schedule isn't known at planning time. + // Warn in the UI on unset times rather than blocking on them. + start_time: string | null + end_time: string | null + shifts: TournamentShift[] created_at: string updated_at: string } +export interface TournamentEventInput { + name?: string | null + division?: TournamentDivision | null + event_type?: 'standard' | 'trial' + event_id?: number | null + building?: string | null + room?: string | null + floor?: string | null + volunteers_needed?: number | null + start_time?: string | null + end_time?: string | null +} + +export interface EventLoadDefaultsSkipped { + event_id: number + division: string + name: string + reason: string +} + +export interface EventLoadDefaultsResponse { + created: TournamentEvent[] + skipped: EventLoadDefaultsSkipped[] +} + export const tournamentEventsApi = { list: (tournamentId: number) => api.get(`/tournaments/${tournamentId}/events/`), get: (tournamentId: number, id: number) => api.get(`/tournaments/${tournamentId}/events/${id}/`), - create: (tournamentId: number, body: Partial) => + create: (tournamentId: number, body: TournamentEventInput & { tournament_id: number }) => api.post(`/tournaments/${tournamentId}/events/`, body), - update: (tournamentId: number, id: number, body: Partial) => + update: (tournamentId: number, id: number, body: Partial) => api.patch(`/tournaments/${tournamentId}/events/${id}/`, body), delete: (tournamentId: number, id: number) => api.delete(`/tournaments/${tournamentId}/events/${id}/`), + // Bulk-creates events from every active SeasonEvent whose division the + // tournament supports. Skips anything already loaded rather than erroring. + loadDefaults: (tournamentId: number) => + api.post(`/tournaments/${tournamentId}/events/load-defaults/`, {}), +} + +// ------------------------------------------------------------------------- +// Season Events — admin-curated per-year/division active event list, drives +// tournamentEventsApi.loadDefaults(). GET is public; writes are admin-only. +// ------------------------------------------------------------------------- +export interface SeasonEvent { + id: number + event_id: number + year: number + division: TournamentDivision + is_active: boolean + event: CanonicalEvent + created_at: string +} + +export interface SeasonEventInput { + event_id: number + year: number + division: TournamentDivision + is_active?: boolean +} + +export const seasonEventsApi = { + list: (params: { year?: number; division?: TournamentDivision } = {}) => { + const qs = new URLSearchParams(); + if (params.year !== undefined) qs.set('year', String(params.year)) + if (params.division) qs.set('division', params.division) + const query = qs.toString() + return api.get(`/season-events/${query ? `?${query}` : ''}`) + }, + create: (body: SeasonEventInput) => + api.post('/admin/season-events/', body), + update: (id: number, body: Partial) => + api.patch(`/admin/season-events/${id}/`, body), + delete: (id: number) => + api.delete(`/admin/season-events/${id}/`), } // ------------------------------------------------------------------------- @@ -483,11 +597,6 @@ export interface AvailabilitySlot { end: string } -export interface ScheduleSlot { - block: number - duty: string -} - // Matches RoleRead export interface Role { id: number @@ -534,8 +643,6 @@ export interface MembershipSlim { export interface MembershipFull { id: number tournament_id: number - assigned_event_id: number | null - schedule: ScheduleSlot[] | null status: MembershipStatus role_preference: string[] | null event_preference: string[] | null @@ -563,8 +670,7 @@ export interface MembershipMeUpdate { // PATCH .../memberships/{id}/ — manage_members override, day-of logistics only export interface MembershipCoordinatorUpdate { - schedule?: ScheduleSlot[] | null - notes?: string | null + notes?: string | null } // GET .../memberships/me/ — current user's membership + effective permissions From 7ab625bf72bff28d9412996ed19d1c328f3dba3b Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 22:38:24 -0700 Subject: [PATCH 11/71] fix(topbar): use hard nav for wordmark link to force full data refetch --- frontend/components/layout/Topbar.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/components/layout/Topbar.tsx b/frontend/components/layout/Topbar.tsx index 60d13491..278258a4 100644 --- a/frontend/components/layout/Topbar.tsx +++ b/frontend/components/layout/Topbar.tsx @@ -11,7 +11,6 @@ import { UserAvatar } from "@/components/ui/UserAvatar"; import { Dropdown } from "@/components/ui/Dropdown"; import { IconPlus } from "@/components/ui/Icons"; import { COLLAPSED_W, EXPANDED_W } from "@/components/layout/Sidebar"; -import Link from "next/link"; interface TopbarProps { showWordmark?: boolean; @@ -109,7 +108,7 @@ export function Topbar({ flexShrink: 0, }}> {showWordmark && ( - NEXUS - + )} {showDropdown && } From 1ca1de6f16dd2d4b136172acd4e94266562d99fb Mon Sep 17 00:00:00 2001 From: ethan shih Date: Thu, 13 Aug 2026 22:41:15 -0700 Subject: [PATCH 12/71] style(profile): darken experience table rows on hover to match member table --- .../components/profile/ExperienceTables.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/components/profile/ExperienceTables.tsx b/frontend/components/profile/ExperienceTables.tsx index 49419c27..4d74ad6d 100644 --- a/frontend/components/profile/ExperienceTables.tsx +++ b/frontend/components/profile/ExperienceTables.tsx @@ -252,7 +252,7 @@ export function CompetitionExperienceSpreadsheet({ {showHoverControls && ( @@ -461,6 +461,13 @@ export function CompetitionExperienceSpreadsheet({ return (
-
e.stopPropagation()} - > -
- -
-
- {children} -
- {footer && ( - // Only this slot gets the containing-block trick — see the prop - // doc above for why it can't be the whole panel. -
- {footer} -
- )} -
-
, - document.body - ); -} diff --git a/frontend/lib/usePanelSelection.tsx b/frontend/lib/usePanelSelection.tsx new file mode 100644 index 00000000..12de23bd --- /dev/null +++ b/frontend/lib/usePanelSelection.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * The two mutually-exclusive ways a table row can open a docked panel: + * + * - **focus**: "Edit"/"Expand" on a row opens that one row's panel. No + * checkboxes; clicking any *other* row switches which one the panel shows. + * - **select**: explicit Select mode — checkboxes accumulate a selection and + * the panel only opens once "Edit" is pressed in the SelectionBar + * (`massPanelOpen`), not as soon as a row is checked. + * + * Both are frozen while `panelDirty` (reported up by whichever panel is open + * via its `onDirtyChange`): switching rows or changing the selection would + * silently throw away in-progress edits, so it's blocked outright — the + * triggering control disables itself with a "save or discard first" title — + * rather than guarded behind a confirm dialog. + */ +export interface PanelSelection { + focusedId: number | null; + selectMode: boolean; + selectedIds: Set; + massPanelOpen: boolean; + panelDirty: boolean; + + /** Pass straight to each panel's `onDirtyChange`. */ + setPanelDirty: (dirty: boolean) => void; + + /** Row "Edit"/"Expand", or clicking another row while one is focused. */ + focusItem: (id: number) => void; + toggleSelectMode: () => void; + toggleSelected: (id: number) => void; + /** Header "select all" checkbox, over the currently visible ids. */ + toggleSelectAll: (ids: number[], checked: boolean) => void; + /** SelectionBar's "Edit" — the only thing that opens the mass panel. */ + openMassPanel: () => void; + + clearFocus: () => void; + clearSelection: () => void; + /** Drop a deleted/removed row out of whichever state still points at it. */ + forgetItem: (id: number) => void; + /** + * Start a caller-owned flow that owns the panel but has no row behind it + * (Events' "new event" draft). Blocked while dirty and clears focus + + * selection first, so it can't silently replace an in-progress edit. + */ + startExternalFlow: (start: () => void) => void; + + getPrevNext: (items: T[], getId: (item: T) => number) => PrevNext; +} + +export interface PrevNext { + /** Index of the focused row in `items`, or -1 (not focused / filtered out). */ + index: number; + hasPrev: boolean; + hasNext: boolean; + prevId: number | null; + nextId: number | null; +} + +export interface PanelSelectionOptions { + /** + * Called whenever this hook takes over the panel (focusing a row, entering + * select mode) so a caller-owned flow with no row behind it — Events' + * "creating a new event" draft — gets dropped at the same time. Members has + * no such flow, so it omits this. + */ + onClearExternal?: () => void; +} + +export function usePanelSelection(options: PanelSelectionOptions = {}): PanelSelection { + const [focusedId, setFocusedId] = useState(null); + const [selectMode, setSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [massPanelOpen, setMassPanelOpen] = useState(false); + const [panelDirty, setPanelDirty] = useState(false); + + // Read through a ref so the actions below keep stable identities even when + // the caller hands in a fresh closure each render — several of them end up + // in a setPanel effect's dependency list, where a new identity per render + // would re-register the panel in a loop. + const clearExternalRef = useRef(options.onClearExternal); + useEffect(() => { clearExternalRef.current = options.onClearExternal; }); + + const clearFocus = useCallback(() => { + setFocusedId(null); + setPanelDirty(false); + }, []); + + const clearSelection = useCallback(() => { + setSelectedIds(new Set()); + setSelectMode(false); + setMassPanelOpen(false); + setPanelDirty(false); + }, []); + + const toggleSelectMode = useCallback(() => { + if (panelDirty) return; + if (selectMode) { + clearSelection(); + return; + } + // Carries whatever was open into the new mode instead of discarding it: + // a focused row becomes the pre-checked row, and its panel stays open + // (massPanelOpen true) rather than dropping back to the SelectionBar — + // there's already a panel showing it, no reason to close it just to make + // you press Edit again. A caller-owned draft (only reachable here while + // clean, since a dirty one is blocked above) is simply dropped. + clearExternalRef.current?.(); + if (focusedId !== null) { + setSelectedIds(new Set([focusedId])); + setFocusedId(null); + setMassPanelOpen(true); + } + setSelectMode(true); + }, [panelDirty, selectMode, focusedId, clearSelection]); + + // Unchecking back down to zero while the mass panel is open closes it — + // there's nothing left to edit — but leaves Select mode itself on, so the + // SelectionBar reappears instead of exiting selection entirely. Applied at + // every point the selection can shrink rather than in an effect watching + // it, which would cost an extra render pass. + const applySelection = useCallback((next: Set) => { + setSelectedIds(next); + if (next.size === 0) setMassPanelOpen(false); + }, []); + + const toggleSelected = useCallback((id: number) => { + if (panelDirty) return; + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); else next.add(id); + applySelection(next); + }, [panelDirty, selectedIds, applySelection]); + + const toggleSelectAll = useCallback((ids: number[], checked: boolean) => { + const next = new Set(selectedIds); + ids.forEach((id) => (checked ? next.add(id) : next.delete(id))); + applySelection(next); + }, [selectedIds, applySelection]); + + // Always lands in plain single-focus mode — it never turns Select mode on, + // and drops out of it (or out of a caller-owned draft) if either was + // already active, replacing whichever panel was open. + const focusItem = useCallback((id: number) => { + if (panelDirty) return; + clearExternalRef.current?.(); + setSelectMode(false); + setSelectedIds(new Set()); + setMassPanelOpen(false); + setFocusedId(id); + }, [panelDirty]); + + const openMassPanel = useCallback(() => setMassPanelOpen(true), []); + + const startExternalFlow = useCallback((start: () => void) => { + if (panelDirty) return; + clearFocus(); + clearSelection(); + start(); + }, [panelDirty, clearFocus, clearSelection]); + + // Otherwise a deleted-but-still-selected/focused row would keep a panel + // open against a row that no longer exists. + const forgetItem = useCallback((id: number) => { + if (selectedIds.has(id)) { + const next = new Set(selectedIds); + next.delete(id); + applySelection(next); + } + setFocusedId((prev) => (prev === id ? null : prev)); + }, [selectedIds, applySelection]); + + // Prev/next only make sense for the plain single-focus flow (not while + // mass-editing several at once) and step through the caller's own current + // filter/sort order, so switching sort or narrowing a filter mid-edit still + // lands somewhere sensible. Returns plain ids/booleans rather than + // callbacks so callers can put them in a dependency array without + // re-registering their panel every render. + const getPrevNext = useCallback((items: T[], getId: (item: T) => number): PrevNext => { + const index = focusedId !== null ? items.findIndex((item) => getId(item) === focusedId) : -1; + const hasPrev = !panelDirty && index > 0; + const hasNext = !panelDirty && index !== -1 && index < items.length - 1; + return { + index, + hasPrev, + hasNext, + prevId: hasPrev ? getId(items[index - 1]) : null, + nextId: hasNext ? getId(items[index + 1]) : null, + }; + }, [focusedId, panelDirty]); + + return { + focusedId, selectMode, selectedIds, massPanelOpen, panelDirty, + setPanelDirty, + focusItem, toggleSelectMode, toggleSelected, toggleSelectAll, openMassPanel, + clearFocus, clearSelection, forgetItem, startExternalFlow, + getPrevNext, + }; +} From 471398c54a07c1614957a36524c88904efb01aa0 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Sun, 16 Aug 2026 23:22:16 -0700 Subject: [PATCH 69/71] feat(members): animate checklist column, hide roles column while member panel is open --- .../tournaments/[id]/members/page.tsx | 98 ++++++++++++------- .../tournament/events/EventsTab.tsx | 56 +++++++---- 2 files changed, 97 insertions(+), 57 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/members/page.tsx b/frontend/app/dashboard/tournaments/[id]/members/page.tsx index f3629024..620e8366 100644 --- a/frontend/app/dashboard/tournaments/[id]/members/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/members/page.tsx @@ -33,7 +33,16 @@ import { IconLock, IconSearch, IconArrowDown, IconExpand, IconTrash, IconMembers // Name / Email / Phone / Account Age / Join Date / Join Method / Status / Roles / Actions const MEMBER_ROW_COLUMNS = "0.8fr 1.2fr 0.6fr 90px 90px 110px 90px 2.6fr 70px"; -const SELECT_COLUMN = "28px "; +// Roles dropped — the fr tracks below just absorb its share automatically. +const MEMBER_ROW_COLUMNS_COMPACT = "0.8fr 1.2fr 0.6fr 90px 90px 110px 90px 70px"; +// Always present as a grid track (never conditionally added/removed) so its +// width can transition between 0 and full instead of popping in — animating +// grid-template-columns only works when the track count stays constant. +const SELECT_COLUMN_WIDTH = "28px"; +function memberColumns(selectMode: boolean, panelOpen: boolean) { + const rest = panelOpen ? MEMBER_ROW_COLUMNS_COMPACT : MEMBER_ROW_COLUMNS; + return `${selectMode ? SELECT_COLUMN_WIDTH : "0px"} ${rest}`; +} const DIRTY_TITLE = "Save or discard your changes first"; @@ -85,7 +94,7 @@ function DurationCell({ iso }: { iso: string }) { function MemberRow({ tournamentId, membership, allRoles, canTouchRole, locked, isSelf, isArchived, onUpdated, onFocus, onRemove, onSelfRemove, isLast, - selectMode, selected, selectionLocked, onToggleSelect, focusActive, focused, rolesReadOnly, + selectMode, selected, selectionLocked, onToggleSelect, focusActive, focused, rolesReadOnly, panelOpen, }: { tournamentId: number; membership: MembershipSlim; @@ -113,6 +122,8 @@ function MemberRow({ focused: boolean; /** This row's roles are open in the docked panel — don't offer a second, inline way to edit the same thing. */ rolesReadOnly: boolean; + /** Any docked panel is open — the table is narrower, so the Roles column drops out to give the rest room. */ + panelOpen: boolean; }) { const [hovered, setHovered] = useState(false); const { user } = membership; @@ -133,19 +144,24 @@ function MemberRow({ onClick={clickable ? handleRowClick : undefined} title={(selectMode || focusActive) ? lockedTitle : undefined} style={{ - display: "grid", gridTemplateColumns: selectMode ? SELECT_COLUMN + MEMBER_ROW_COLUMNS : MEMBER_ROW_COLUMNS, alignItems: "center", + display: "grid", gridTemplateColumns: memberColumns(selectMode, panelOpen), alignItems: "center", gap: "10px", padding: "10px 12px", borderBottom: isLast ? "none" : "1px solid var(--color-border)", background: highlighted ? "var(--color-bg)" : hovered ? "var(--color-bg)" : "transparent", - transition: "background 100ms ease", + transition: "background 100ms ease, grid-template-columns 200ms ease", cursor: clickable ? "pointer" : selectionLocked ? "not-allowed" : "default", }} > - {selectMode && ( - e.stopPropagation()}> - - - )} + e.stopPropagation()} + > + +
{membership.status} - {/* Stops row clicks (select toggle / focus switch) from firing when - the intent was to pick a role chip. */} -
e.stopPropagation()} style={{ minWidth: 0 }}> - -
+ {!panelOpen && ( + // Stops row clicks (select toggle / focus switch) from firing when + // the intent was to pick a role chip. +
e.stopPropagation()} style={{ minWidth: 0 }}> + +
+ )}
e.stopPropagation()}> {!isArchived && ( + {isMembersFilterActive(filters) && ( + + )} )} + {showFilterModal && ( + setShowFilterModal(false)} + /> + )} + {/* Stays up through the whole "checking boxes" phase — the panel only opens once Edit is pressed here, not as soon as one row is checked. */} ; + +export function isMembersFilterActive(filters: MembersFilterState): boolean { + return isFilterActive(filters); +} + +interface MembersFilterModalProps { + roleOptions: FilterOption[]; + statusOptions: FilterOption[]; + filters: MembersFilterState; + /** Fires on Apply only — the modal closes itself afterwards. */ + onApply: (filters: MembersFilterState) => void; + onClose: () => void; +} + +// Roles are open-ended (one per tournament role, plus "No roles"), so they get +// the checkbox list; status has two fixed values, so it gets the button group +// — same split Events uses for Category vs. Division/Type. +export function MembersFilterModal({ roleOptions, statusOptions, filters, onApply, onClose }: MembersFilterModalProps) { + const sections: FilterSectionConfig[] = [ + { key: "status", title: "Status", options: statusOptions, control: "buttons" }, + { key: "role", title: "Roles", options: roleOptions, control: "checkbox" }, + ]; + + return ( + + ); +} diff --git a/frontend/components/tournament/events/EventsFilterModal.tsx b/frontend/components/tournament/events/EventsFilterModal.tsx index 11d85e1f..66f657e9 100644 --- a/frontend/components/tournament/events/EventsFilterModal.tsx +++ b/frontend/components/tournament/events/EventsFilterModal.tsx @@ -1,111 +1,16 @@ "use client"; -import { Modal } from "@/components/ui/Modal"; -import { Button } from "@/components/ui/Button"; -import { Checkbox } from "@/components/ui/Checkbox"; -import { ButtonGroup } from "@/components/ui/ButtonGroup"; +import { + FilterModal, FilterOption, FilterSectionConfig, FilterState, isFilterActive, +} from "@/components/ui/FilterModal"; -export interface FilterOption { - value: string; - label: string; -} - -interface SectionHeaderProps { - title: string; - options: FilterOption[]; - excluded: Set; - onChange: (excluded: Set) => void; -} - -// Just one toggle button, not both at once: "Deselect all" only makes sense -// while something is still selected, and vice versa. -function SectionHeader({ title, options, excluded, onChange }: SectionHeaderProps) { - const allSelected = excluded.size === 0; - - return ( -
- - {title} - - {allSelected ? ( - - ) : ( - - )} -
- ); -} - -interface FilterSectionProps { - title: string; - options: FilterOption[]; - /** Values excluded from the result set — empty means "everything shown". */ - excluded: Set; - onChange: (excluded: Set) => void; -} +export const EVENTS_FILTER_KEYS = ["division", "type", "category"] as const; +type EventsFilterKey = (typeof EVENTS_FILTER_KEYS)[number]; -function CheckboxFilterSection({ title, options, excluded, onChange }: FilterSectionProps) { - function toggle(value: string) { - const next = new Set(excluded); - next.has(value) ? next.delete(value) : next.add(value); - onChange(next); - } - - return ( -
- -
- {options.map((opt) => ( - - ))} -
-
- ); -} - -function ButtonGroupFilterSection({ title, options, excluded, onChange }: FilterSectionProps) { - function toggle(value: string) { - const next = new Set(excluded); - next.has(value) ? next.delete(value) : next.add(value); - onChange(next); - } - - return ( -
- - !excluded.has(o.value)).map((o) => o.value)} - onChange={toggle} - /> -
- ); -} - -// Values excluded per field — empty means that field applies no filtering, -// which keeps newly-appearing options (e.g. a category from a just-loaded -// event) shown by default instead of needing to be synced into an -// "everything selected" baseline. -export interface EventsFilterState { - division: Set; - type: Set; - category: Set; -} +export type EventsFilterState = FilterState; export function isEventsFilterActive(filters: EventsFilterState): boolean { - return filters.division.size > 0 || filters.type.size > 0 || filters.category.size > 0; + return isFilterActive(filters); } interface EventsFilterModalProps { @@ -113,35 +18,27 @@ interface EventsFilterModalProps { typeOptions: FilterOption[]; categoryOptions: FilterOption[]; filters: EventsFilterState; - onChange: (filters: EventsFilterState) => void; + /** Fires on Apply only — the modal closes itself afterwards. */ + onApply: (filters: EventsFilterState) => void; onClose: () => void; } -export function EventsFilterModal({ divisionOptions, typeOptions, categoryOptions, filters, onChange, onClose }: EventsFilterModalProps) { - return ( - - onChange({ ...filters, division })} - /> - onChange({ ...filters, type })} - /> - onChange({ ...filters, category })} - /> +// Division/Type have a handful of fixed values (button group); Category is +// open-ended and grows with the event list (checkbox list). +export function EventsFilterModal({ divisionOptions, typeOptions, categoryOptions, filters, onApply, onClose }: EventsFilterModalProps) { + const sections: FilterSectionConfig[] = [ + { key: "division", title: "Division", options: divisionOptions, control: "buttons" }, + { key: "type", title: "Type", options: typeOptions, control: "buttons" }, + { key: "category", title: "Category", options: categoryOptions, control: "checkbox" }, + ]; -
- -
-
+ return ( + ); } diff --git a/frontend/components/tournament/events/EventsTab.tsx b/frontend/components/tournament/events/EventsTab.tsx index 44958654..f201961c 100644 --- a/frontend/components/tournament/events/EventsTab.tsx +++ b/frontend/components/tournament/events/EventsTab.tsx @@ -20,7 +20,10 @@ import { useSetLayoutPanel } from "@/lib/useLayoutPanel"; import { usePanelSelection } from "@/lib/usePanelSelection"; import { EventPanel, EVENT_PANEL_WIDTH } from "@/components/tournament/events/EventPanel"; import { DeleteEventModal } from "@/components/tournament/events/DeleteEventModal"; -import { EventsFilterModal, EventsFilterState, isEventsFilterActive } from "@/components/tournament/events/EventsFilterModal"; +import { EventsFilterModal, isEventsFilterActive, EVENTS_FILTER_KEYS } from "@/components/tournament/events/EventsFilterModal"; +import { emptyFilterState } from "@/components/ui/FilterModal"; +import { usePersistedFilter } from "@/lib/usePersistedFilter"; +import { useAuth } from "@/lib/useAuth"; import { MassEventEditor, MASS_EVENT_EDITOR_WIDTH } from "@/components/tournament/events/MassEventEditor"; import { eventName } from "@/lib/eventDisplay"; @@ -78,6 +81,7 @@ interface EventsTabProps { export function EventsTab({ tournamentId, canManageEvents }: EventsTabProps) { const router = useRouter(); + const { user } = useAuth(); const { selectedTournament, isArchived } = useTournament(); const divisions = selectedTournament?.division ?? []; const hasDivisions = divisions.length > 0; @@ -91,7 +95,8 @@ export function EventsTab({ tournamentId, canManageEvents }: EventsTabProps) { const [deleteTarget, setDeleteTarget] = useState(null); const [search, setSearch] = useState(""); - const [filters, setFilters] = useState({ division: new Set(), type: new Set(), category: new Set() }); + // Committed filters only — the modal keeps its own draft until Apply. + const [filters, applyFilters] = usePersistedFilter("events", user?.id, tournamentId, EVENTS_FILTER_KEYS); const [showFilterModal, setShowFilterModal] = useState(false); const [sortField, setSortField] = useState("start_time"); const [sortDir, setSortDir] = useState("asc"); @@ -348,7 +353,7 @@ export function EventsTab({ tournamentId, canManageEvents }: EventsTabProps) { {isEventsFilterActive(filters) && ( @@ -454,7 +459,7 @@ export function EventsTab({ tournamentId, canManageEvents }: EventsTabProps) { typeOptions={TYPE_OPTIONS} categoryOptions={categoryOptions} filters={filters} - onChange={setFilters} + onApply={applyFilters} onClose={() => setShowFilterModal(false)} /> )} diff --git a/frontend/components/ui/FilterModal.tsx b/frontend/components/ui/FilterModal.tsx new file mode 100644 index 00000000..e23c1cdb --- /dev/null +++ b/frontend/components/ui/FilterModal.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useState } from "react"; +import { Modal } from "@/components/ui/Modal"; +import { Button } from "@/components/ui/Button"; +import { Checkbox } from "@/components/ui/Checkbox"; +import { ButtonGroup } from "@/components/ui/ButtonGroup"; + +export interface FilterOption { + value: string; + label: string; +} + +// Values excluded per field — empty means that field applies no filtering, +// which keeps newly-appearing options (e.g. a category from a just-loaded +// event) shown by default instead of needing to be synced into an +// "everything selected" baseline. +export type FilterState = Record>; + +export function emptyFilterState(keys: readonly K[]): FilterState { + return Object.fromEntries(keys.map((k) => [k, new Set()])) as FilterState; +} + +export function isFilterActive(filters: FilterState): boolean { + return Object.values(filters).some((excluded) => excluded.size > 0); +} + +export interface FilterSectionConfig { + /** Field this section edits — must be a key of the filter state. */ + key: K; + title: string; + options: FilterOption[]; + /** "buttons" for a handful of fixed values, "checkbox" for open-ended lists. */ + control: "buttons" | "checkbox"; +} + +interface SectionHeaderProps { + title: string; + options: FilterOption[]; + excluded: Set; + onChange: (excluded: Set) => void; +} + +// Just one toggle button, not both at once: "Deselect all" only makes sense +// while something is still selected, and vice versa. +function SectionHeader({ title, options, excluded, onChange }: SectionHeaderProps) { + const allSelected = excluded.size === 0; + + return ( +
+ + {title} + + {allSelected ? ( + + ) : ( + + )} +
+ ); +} + +interface FilterSectionProps { + title: string; + options: FilterOption[]; + /** Values excluded from the result set — empty means "everything shown". */ + excluded: Set; + onChange: (excluded: Set) => void; +} + +export function CheckboxFilterSection({ title, options, excluded, onChange }: FilterSectionProps) { + function toggle(value: string) { + const next = new Set(excluded); + next.has(value) ? next.delete(value) : next.add(value); + onChange(next); + } + + return ( +
+ +
+ {options.map((opt) => ( + + ))} +
+
+ ); +} + +export function ButtonGroupFilterSection({ title, options, excluded, onChange }: FilterSectionProps) { + function toggle(value: string) { + const next = new Set(excluded); + next.has(value) ? next.delete(value) : next.add(value); + onChange(next); + } + + return ( +
+ + !excluded.has(o.value)).map((o) => o.value)} + onChange={toggle} + /> +
+ ); +} + +interface FilterModalProps { + title: string; + sections: FilterSectionConfig[]; + /** Currently-applied filters — seeds the draft when the modal mounts. */ + filters: FilterState; + /** Fired only on Apply; the modal closes itself afterwards. */ + onApply: (filters: FilterState) => void; + /** X / overlay / Escape / Cancel — the draft is thrown away. */ + onClose: () => void; + width?: number; +} + +// Apply-gated: edits land in a local draft and the caller's state only moves +// on Apply. Callers mount this conditionally (`{open && }`), +// so the draft is re-seeded from the applied filters on every open rather than +// resuming a stale one. +export function FilterModal({ title, sections, filters, onApply, onClose, width = 380 }: FilterModalProps) { + const [draft, setDraft] = useState>(filters); + + function setField(key: K, excluded: Set) { + setDraft((prev) => ({ ...prev, [key]: excluded })); + } + + return ( + + {sections.map((section) => { + const Section = section.control === "checkbox" ? CheckboxFilterSection : ButtonGroupFilterSection; + return ( +
setField(section.key, excluded)} + /> + ); + })} + +
+ + +
+ + ); +} diff --git a/frontend/lib/usePersistedFilter.tsx b/frontend/lib/usePersistedFilter.tsx new file mode 100644 index 00000000..b758bb6e --- /dev/null +++ b/frontend/lib/usePersistedFilter.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { FilterState, emptyFilterState } from "@/components/ui/FilterModal"; + +// Scoped per user *and* tournament: logout() doesn't clear localStorage, so an +// unscoped key would leak one account's filters into the next one on a shared +// device (and one tournament's into another). +function storageKeyFor(table: string, userId: number | undefined, tournamentId: number | undefined): string | null { + if (userId == null || tournamentId == null || !Number.isFinite(tournamentId)) return null; + return `filters:${userId}:${tournamentId}:${table}`; +} + +/** + * Committed filter state for one table, mirrored into localStorage. + * + * Sets don't survive JSON.stringify (they serialize to `{}`), so the stored + * shape is `{ field: string[] }` and this hook converts both ways. + * + * Returns the applied filters plus a setter that persists — call it on Apply + * / Clear only, never from a draft edit. + */ +export function usePersistedFilter( + table: string, + userId: number | undefined, + tournamentId: number | undefined, + keys: readonly K[], +): [FilterState, (next: FilterState) => void] { + // The caller passes an array literal or const tuple; pinning it in a ref + // keeps it out of effect/callback deps without risking a stale-identity loop. + const keysRef = useRef(keys); + keysRef.current = keys; + + const [filters, setFilters] = useState>(() => emptyFilterState(keys)); + + const storageKey = storageKeyFor(table, userId, tournamentId); + + // Hydrate once per key. `null` (user/tournament still loading) is simply + // skipped — the effect re-runs when the key materializes. + const hydratedKey = useRef(null); + useEffect(() => { + if (!storageKey || hydratedKey.current === storageKey) return; + hydratedKey.current = storageKey; + const next = emptyFilterState(keysRef.current); + try { + const raw = window.localStorage.getItem(storageKey); + if (raw) { + const parsed = JSON.parse(raw) as Record; + for (const key of keysRef.current) { + const values = parsed[key]; + if (Array.isArray(values)) next[key] = new Set(values.filter((v): v is string => typeof v === "string")); + } + } + } catch { + // Corrupt entry or storage unavailable (private mode) — fall back to + // "no filters" rather than breaking the page. + } + setFilters(next); + }, [storageKey]); + + const apply = useCallback((next: FilterState) => { + setFilters(next); + if (!storageKey) return; + try { + const plain = Object.fromEntries(keysRef.current.map((k) => [k, [...next[k]]])); + const anyActive = Object.values(plain).some((values) => values.length > 0); + // Clearing removes the entry outright so a stale "everything shown" + // blob doesn't linger in storage forever. + if (anyActive) window.localStorage.setItem(storageKey, JSON.stringify(plain)); + else window.localStorage.removeItem(storageKey); + } catch { + // Quota/private-mode failures shouldn't block the in-memory filter. + } + }, [storageKey]); + + return [filters, apply]; +} From f7dfce91b8dc7147ed90aafff170e56c9aa1f943 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Sun, 16 Aug 2026 23:40:37 -0700 Subject: [PATCH 71/71] fix(members): never hide members with no roles from the roles filter --- .../tournaments/[id]/members/page.tsx | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/members/page.tsx b/frontend/app/dashboard/tournaments/[id]/members/page.tsx index fbe57938..f73ea225 100644 --- a/frontend/app/dashboard/tournaments/[id]/members/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/members/page.tsx @@ -64,10 +64,6 @@ const STATUS_FILTER_OPTIONS = [ { value: "confirmed", label: "Confirmed" }, ]; -// Sentinel for "member has no roles at all", so it can sit in the same -// excluded-values Set as real role ids — mirrors the Events tab's UNSET. -const NO_ROLES = "__none__"; - function memberName(m: MembershipSlim): string { return `${m.user.first_name ?? ""} ${m.user.last_name ?? ""}`.trim() || m.user.email; } @@ -286,9 +282,11 @@ export default function MembersPage() { if (q && !memberName(m).toLowerCase().includes(q) && !m.user.email.toLowerCase().includes(q)) return false; // A member is hidden only when *every* role they hold is excluded — // otherwise someone with a kept role would vanish for holding an - // unrelated excluded one. - const roleKeys = m.roles.length > 0 ? m.roles.map((r) => String(r.id)) : [NO_ROLES]; - if (roleKeys.every((k) => filters.role.has(k))) return false; + // unrelated excluded one. A member with no roles at all is never + // hidden by this filter — deselecting every role should surface the + // unassigned members, not hide them along with everyone else. + const roleKeys = m.roles.map((r) => String(r.id)); + if (roleKeys.length > 0 && roleKeys.every((k) => filters.role.has(k))) return false; if (filters.status.has(m.status)) return false; return true; }); @@ -301,12 +299,10 @@ export default function MembersPage() { return sorted; }, [members, search, filters, sortField, sortDir]); - const roleFilterOptions = useMemo(() => { - const opts = allRoles.map((r) => ({ value: String(r.id), label: r.label })); - return (members ?? []).some((m) => m.roles.length === 0) - ? [...opts, { value: NO_ROLES, label: "No roles" }] - : opts; - }, [allRoles, members]); + const roleFilterOptions = useMemo( + () => allRoles.map((r) => ({ value: String(r.id), label: r.label })), + [allRoles] + ); const handleMemberUpdated = useCallback((updated: MembershipSlim) => { setMembers((prev) => prev && prev.map((m) => (m.id === updated.id ? updated : m)));