Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
db9229e
refactor: drop TournamentMembership.assigned_event_id and .schedule
ethnjs Aug 13, 2026
1fbd317
feat(shift): add TournamentShift model and tournament_event_shifts br…
ethnjs Aug 13, 2026
295ab39
feat(event): rewrite TournamentEvent table — drop blocks/category, li…
ethnjs Aug 13, 2026
858ebac
feat(event): add SeasonEvent table — admin-curated per-year/division …
ethnjs Aug 13, 2026
6d65c47
feat(event): rewrite tournament event schemas for canonical catalog l…
ethnjs Aug 13, 2026
4f98969
feat(event): update tournament events routes/tests for canonical cata…
ethnjs Aug 14, 2026
52be42c
feat(event): add TournamentShift CRUD routes and event/shift attach-d…
ethnjs Aug 14, 2026
83a3252
feat(event): add SeasonEvent admin routes and tournament events bulk-…
ethnjs Aug 14, 2026
3f19e4a
refactor(event): split canonical event and season-event admin writes …
ethnjs Aug 14, 2026
8217fab
feat(frontend): update api.ts for tournament events backend rewrite —…
ethnjs Aug 14, 2026
7ab625b
fix(topbar): use hard nav for wordmark link to force full data refetch
ethnjs Aug 14, 2026
1ca1de6
style(profile): darken experience table rows on hover to match member…
ethnjs Aug 14, 2026
7ef4103
refactor(ui): extract roles editor tab strip into shared TabStrip com…
ethnjs Aug 14, 2026
d8a8185
fix(sidebar): hide Events nav link for members without manage_events …
ethnjs Aug 14, 2026
643f1ee
feat(events): add tournament events page shell with tab strip and per…
ethnjs Aug 14, 2026
6f0f56f
feat(events): add read-only events table with search, filters, sort, …
ethnjs Aug 14, 2026
17abb1c
feat(icons): update events and members icons, add empty-state icons f…
ethnjs Aug 14, 2026
fff7d48
feat(events): seed 2027 season events for Division B/C
ethnjs Aug 14, 2026
6b629fc
fix(tournament): prevent admins from removing the tournament owner's …
ethnjs Aug 14, 2026
fb844e1
fix(tournaments): show not-found screen instead of infinite loading f…
ethnjs Aug 14, 2026
142eb0d
fix(tournament): stop stale tournament-list refresh from racing the U…
ethnjs Aug 14, 2026
db8a9b4
feat(events): guard default-event loading on missing divisions, add c…
ethnjs Aug 14, 2026
0d280a1
fix(tournament): show per-field save errors on general settings inste…
ethnjs Aug 14, 2026
231c8c9
refactor(season-events): drop redundant event_id from read schema, su…
ethnjs Aug 14, 2026
b0ddd69
fix(tournament): resolve type narrowing errors on general settings sa…
ethnjs Aug 14, 2026
c7c315b
feat(events): add event side panel with create/edit/delete and shift …
ethnjs Aug 14, 2026
b6062c9
feat(events): replace division/type dropdown filters with a multisele…
ethnjs Aug 14, 2026
53d33e5
refactor(events): only show one select/deselect-all toggle per filter…
ethnjs Aug 14, 2026
248f8bf
feat(events): scope floating save bar to the event panel, guard unsav…
ethnjs Aug 14, 2026
a434139
feat(shifts): add event_count to TournamentShiftRead for the delete-c…
ethnjs Aug 14, 2026
b9d5a6e
feat(shifts): add inline-editable shifts table with batched save and …
ethnjs Aug 14, 2026
f9056c5
refactor(shifts): switch to per-row edit toggle with FloatingSaveBar …
ethnjs Aug 14, 2026
5edb315
feat(shifts): guard tab switches on unsaved shift edits, allow multip…
ethnjs Aug 14, 2026
491455e
feat(shifts): show per-field validation errors on the offending input…
ethnjs Aug 14, 2026
b13cc34
feat(shifts): make row edit-mode count as dirty, turn Edit into a per…
ethnjs Aug 14, 2026
51b308e
style(shifts): darken row background on hover, matching the events/me…
ethnjs Aug 14, 2026
692097e
refactor(tournaments): extract TournamentPublic base shared by dashbo…
ethnjs Aug 14, 2026
fb9f10e
refactor(tournaments): consume TournamentSummary on the dashboard lis…
ethnjs Aug 14, 2026
987a2a3
test(tournaments): cover TournamentSummary shape on GET /tournaments/me/
ethnjs Aug 14, 2026
ff5ee3c
fix(input): cap datetime-local max at 4-digit year like date inputs
ethnjs Aug 14, 2026
4b58fc4
fix(tournaments): validate event/shift times against tournament bound…
ethnjs Aug 14, 2026
dcc36fc
test(tournaments): cover event/shift tournament-bounds and attach-wit…
ethnjs Aug 15, 2026
2330d07
fix(tournaments): add tournament timezone, compare shift/event bounds…
ethnjs Aug 15, 2026
2d435a1
feat(tournaments): send browser timezone silently on tournament create
ethnjs Aug 15, 2026
1ba2cda
fix(events): surface tournament-bounds 409s on the offending time inp…
ethnjs Aug 15, 2026
44ff1cc
fix(events): clamp event and shift datetime inputs to the tournament'…
ethnjs Aug 15, 2026
7f9ce5b
feat(shifts): replace shift-events overlay with inline split-view car…
ethnjs Aug 15, 2026
741a9b0
fix(events): scope the save-bar containing-block fix to a dedicated f…
ethnjs Aug 15, 2026
ccba8e1
feat(events): redesign shift/event attach UI with condensed times, ad…
ethnjs Aug 15, 2026
d2c736f
feat(events): split event/shift start-end into day+time fields, lock …
ethnjs Aug 15, 2026
d316fd9
feat(events): color-code division and type badges in events table, ad…
ethnjs Aug 15, 2026
9ea6f4f
feat: add multi-row select mode to events and members tables
ethnjs Aug 15, 2026
5c08534
feat(events): add create-new-shift popover to event panel's shift sec…
ethnjs Aug 15, 2026
1485f0a
feat(events): add mass edit for division, type, start, and end time
ethnjs Aug 15, 2026
e2d3074
feat(events): floating selection bar, click-anywhere row select
ethnjs Aug 15, 2026
1af066f
feat(events): rename expand button to edit, move left of delete
ethnjs Aug 15, 2026
0c52a06
fix(events): don't show floating save bar on open for single-day tour…
ethnjs Aug 15, 2026
002fe70
feat(events): add shift attach/create to mass event editor
ethnjs Aug 15, 2026
2727d02
feat(events): defer mass shift changes to save, add remove-shift, shr…
ethnjs Aug 15, 2026
df93b8d
feat(members): add mass role editor with add/remove and diff preview
ethnjs Aug 17, 2026
17faafd
feat: use minus icon for remove-shift and remove-role buttons
ethnjs Aug 17, 2026
df45645
fix(ui): flip popover above trigger when there's no room below
ethnjs Aug 17, 2026
660a811
feat(members): show locked roles instead of hiding them in mass role …
ethnjs Aug 17, 2026
382f921
fix(members): keep delete button red when disabled instead of graying…
ethnjs Aug 17, 2026
631faf7
feat(events): unify single and mass edit into one selection-driven panel
ethnjs Aug 17, 2026
5b8b10d
feat(events): dock event panel in layout instead of modal overlay, fi…
ethnjs Aug 17, 2026
0900963
feat(events): add prev/next navigation arrows to single-event panel
ethnjs Aug 17, 2026
c611f33
feat(members): dock member panels, unify select/edit flow, extract sh…
ethnjs Aug 17, 2026
471398c
feat(members): animate checklist column, hide roles column while memb…
ethnjs Aug 17, 2026
497dfc3
feat(events,members): extract shared apply-gated filter modal, persis…
ethnjs Aug 17, 2026
f7dfce9
fix(members): never hide members with no roles from the roles filter
ethnjs Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""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.

- 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`, 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
backfill, starts empty.

- tournaments: add `timezone` (IANA name), NOT NULL. Set once at
creation from the creator's browser timezone, immutable after —
no update path. Existing rows backfilled to "America/Los_Angeles".
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text


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")

# ------------------------------------------------------------------
# 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),
)

# ------------------------------------------------------------------
# tournament_events — drop blocks/category, add event_id, add
# 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)
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),
)
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))

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"),
)

op.alter_column("tournament_events", "volunteers_needed", existing_type=sa.Integer(), nullable=True)

# ------------------------------------------------------------------
# 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"),
)

# ------------------------------------------------------------------
# tournaments — add timezone (backfilled, then locked to NOT NULL)
# ------------------------------------------------------------------
op.add_column(
"tournaments",
sa.Column("timezone", sa.String(length=64), nullable=False, server_default="America/Los_Angeles"),
)
op.alter_column("tournaments", "timezone", server_default=None)


def downgrade() -> None:
# ------------------------------------------------------------------
# tournaments — drop timezone
# ------------------------------------------------------------------
op.drop_column("tournaments", "timezone")

# ------------------------------------------------------------------
# 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
# 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")

# 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")

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
# ------------------------------------------------------------------
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.
# ------------------------------------------------------------------
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",
)
31 changes: 18 additions & 13 deletions backend/app/api/routes/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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",
)
)
101 changes: 101 additions & 0 deletions backend/app/api/routes/season_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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

# 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 /season-events/ — filterable by year, and by one or more divisions
# (repeat the query param, e.g. ?division=B&division=C)
# ---------------------------------------------------------------------------
@router.get("/", response_model=list[SeasonEventRead])
def list_season_events(
year: int | None = Query(None),
division: list[str] | None = Query(None),
db: Session = Depends(get_db),
):
query = db.query(SeasonEvent).options(joinedload(SeasonEvent.event))
if year is not None:
query = query.filter(SeasonEvent.year == year)
if division:
query = query.filter(SeasonEvent.division.in_(division))
return query.order_by(SeasonEvent.year.desc(), SeasonEvent.division).all()


# ---------------------------------------------------------------------------
# POST /admin/season-events/ — admin only
# ---------------------------------------------------------------------------
@admin_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
# ---------------------------------------------------------------------------
@admin_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
# ---------------------------------------------------------------------------
@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),
_: 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()
Loading
Loading