Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Formats staged backend Python files and sorts their imports.
# Lints and formats staged backend Python files, applying Ruff's safe fixes.
# Files with unstaged changes are only checked, so the hook never stages work you left out.
set -euo pipefail

Expand All @@ -24,21 +24,26 @@ done

cd "$root/backend"
if [[ ${#fix[@]} -gt 0 ]]; then
uv run --quiet ruff check --select I --fix --quiet "${fix[@]}"
lint=0
uv run --quiet ruff check --fix --quiet "${fix[@]}" || lint=1
uv run --quiet ruff format --quiet "${fix[@]}"
git add -- "${fix[@]}"
if [[ $lint -ne 0 ]]; then
echo "pre-commit: fix the lint errors above, then commit again." >&2
exit 1
fi
fi
bad=()
for f in "${check[@]}"; do
# Check the staged version, which is what gets committed.
if ! git show ":backend/$f" | uv run --quiet ruff format --check --quiet --stdin-filename "$f" - \
|| ! git show ":backend/$f" | uv run --quiet ruff check --select I --quiet --stdin-filename "$f" - >/dev/null; then
|| ! git show ":backend/$f" | uv run --quiet ruff check --quiet --stdin-filename "$f" -; then
bad+=("$f")
fi
done
if [[ ${#bad[@]} -gt 0 ]]; then
echo "pre-commit: these files have unstaged changes, so they weren't fixed:" >&2
printf ' backend/%s\n' "${bad[@]}" >&2
echo "Run 'uv run ruff format' and 'uv run ruff check --select I --fix' in backend/, then stage." >&2
echo "Run 'uv run ruff check --fix' and 'uv run ruff format' in backend/, then stage." >&2
exit 1
fi
4 changes: 2 additions & 2 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ jobs:
- name: Install dependencies
run: uv sync --locked

- name: Check formatting
- name: Lint and check formatting
run: |
uv run ruff check .
uv run ruff format --check .
uv run ruff check --select I .

- name: Run pytest
env:
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ git config core.hooksPath .githooks
git config blame.ignoreRevsFile .git-blame-ignore-revs
```

The pre-commit hook formats staged backend files with Ruff and sorts their imports. CI runs the same check. To run it by hand from `backend/`: `uv run ruff format` and `uv run ruff check --select I --fix`.
The pre-commit hook lints and formats staged backend files with Ruff, applying its safe fixes. CI runs the same checks. To run them by hand from `backend/`: `uv run ruff check --fix` and `uv run ruff format`.

Swagger UI: [http://localhost:8001/docs](http://localhost:8001/docs)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ def upgrade() -> None:
import json
import re

for field_id, form_id, label, old_key, config in fields:
for field_id, form_id, label, _old_key, config in fields:
# field_key is varchar(64); a question label is a whole sentence, so
# the slug has to be clipped before the dedup suffix is appended.
base = re.sub(r"[^a-z0-9]+", "_", (label or "question").lower()).strip("_") or "question"
Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/routes/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def delete_event(event_id: int, db: Session = Depends(get_db), _: User = Depends
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot delete event: it has associated competition or volunteer experience entries",
)
) from None


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -163,4 +163,4 @@ def delete_event_category(
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot delete category: one or more of its events has associated experience entries",
)
) from None
9 changes: 6 additions & 3 deletions backend/app/api/routes/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,9 @@ def update_form(
try:
validate_form_for_publish(db, form)
except FormFieldValidationError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e))
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)
) from e

if payload.name is not None:
form.name = payload.name
Expand Down Expand Up @@ -770,7 +772,9 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) ->
db, form.tournament_id, field_key, question_type, normalized
)
except FormFieldValidationError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e))
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)
) from e
return normalized

# (field, reasons, removed_option_ids) — resolved into rows after the
Expand All @@ -787,7 +791,6 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) ->
# Nothing is flagged: the question and its answers are exactly as
# they were left, so there's nothing for a responder to review.
unarchiving = field.is_archived
type_changed = entry.question_type != field.question_type
# entry.field_key is None/blank when the caller isn't renaming
# this field at all (the common case — most edits touch label/
# config, not the key) — that means "leave it alone", not "set it
Expand Down
6 changes: 3 additions & 3 deletions backend/app/api/routes/join.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def _preview_chapter_code(join_code: JoinCode, db: Session) -> JoinPreviewChapte
@router.post("/join/", response_model=JoinRedeemResponse, status_code=status.HTTP_201_CREATED)
def redeem_join_code(
code: str = Query(...),
body: JoinRedeemRequest = JoinRedeemRequest(),
body: JoinRedeemRequest = JoinRedeemRequest(), # noqa: B008
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
Expand Down Expand Up @@ -175,7 +175,7 @@ def _redeem_tournament_code(
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Already a member of this tournament"
)
) from None

return JoinRedeemResponse(
type="tournament", target_id=membership.tournament_id, membership_id=membership.id
Expand Down Expand Up @@ -209,7 +209,7 @@ def _redeem_chapter_code(
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="User is already a member of a chapter"
)
) from None

return JoinRedeemResponse(
type="chapter", target_id=membership.chapter_id, membership_id=membership.id
Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/routes/season_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def create_season_event(
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A season event for this event/year/division already exists",
)
) from None
db.refresh(season_event)
return season_event

Expand Down Expand Up @@ -80,7 +80,7 @@ def update_season_event(
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A season event for this event/year/division already exists",
)
) from None
db.refresh(season_event)
return season_event

Expand Down
16 changes: 8 additions & 8 deletions backend/app/api/routes/sheets.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ def validate_sheet(
try:
return svc.validate_sheet_url(payload.sheet_url)
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e


# ---------------------------------------------------------------------------
Expand All @@ -89,9 +89,9 @@ def get_sheet_headers(
try:
form_questions = forms_svc.get_form_questions(payload.form_url)
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e

try:
return svc.get_headers(
Expand All @@ -101,9 +101,9 @@ def get_sheet_headers(
form_questions=form_questions,
)
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -255,6 +255,6 @@ def sync_sheet_config(
try:
return sync_sheet(config, db, svc) # fixed arg order: config, db, sheets_svc
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
6 changes: 3 additions & 3 deletions backend/app/api/routes/tournament/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import UTC, date, datetime, timezone
from datetime import UTC, date, datetime

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
Expand Down Expand Up @@ -111,7 +111,7 @@ def create_tournament(
db.flush()
except ValueError as e:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e

# Membership for the creator, no roles yet — owner_id alone already grants
# full permissions.
Expand Down Expand Up @@ -168,7 +168,7 @@ def update_tournament(
db.commit()
except ValueError as e:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e

db.refresh(tournament)
return _serialize(tournament)
Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/routes/tournament/assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ def _flush_or_conflict(db: Session) -> None:
"That event, shift and track don't belong together — the "
"event must run on the track, and a shift must be on it too"
),
)
) from error
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This member is already assigned to that event in that role and shift",
)
) from error


# ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/routes/tournament/buildings.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def create_building(
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A building with this name already exists",
)
) from None
return TournamentBuildingRead.from_row(building)


Expand Down Expand Up @@ -235,7 +235,7 @@ def update_building(
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A building with this name already exists",
)
) from None
return TournamentBuildingRead.from_row(building)


Expand Down
8 changes: 5 additions & 3 deletions backend/app/api/routes/tournament/events.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from itertools import pairwise

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload, selectinload
Expand Down Expand Up @@ -302,7 +304,7 @@ def _validate_no_overlap(shifts: list[TournamentShift]) -> None:
event once at a time, which is the rule assignments build on. Adjacent
(end == start) is fine; only strict overlap is rejected."""
ordered = sorted(shifts, key=lambda s: s.start)
for earlier, later in zip(ordered, ordered[1:]):
for earlier, later in pairwise(ordered):
if later.start < earlier.end:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
Expand Down Expand Up @@ -426,7 +428,7 @@ def create_event(
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This catalog event already exists in this tournament for this division",
)
) from None
db.refresh(event)
return event

Expand Down Expand Up @@ -473,7 +475,7 @@ def update_event(
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This catalog event already exists in this tournament for this division",
)
) from None
db.refresh(event)
return event

Expand Down
6 changes: 4 additions & 2 deletions backend/app/api/routes/tournament/export_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ def _check_sorts(sorts: list[ExportPresetSort], columns: list[ExportPresetColumn
try:
validate_sorts_against_columns(sorts, columns)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc


def _commit(db: Session, preset: TournamentExportPreset) -> None:
Expand All @@ -52,7 +54,7 @@ def _commit(db: Session, preset: TournamentExportPreset) -> None:
db.refresh(preset)
except IntegrityError:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=_NAME_TAKEN)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=_NAME_TAKEN) from None


# ---------------------------------------------------------------------------
Expand Down
9 changes: 4 additions & 5 deletions backend/app/api/routes/tournament/memberships.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def _resolve_join_code_creators(
if not creator_ids:
return
creators = resolve_person_refs(db, tournament_id, creator_ids)
for m, resp in zip(memberships, responses):
for m, resp in zip(memberships, responses, strict=True):
if m.join_code is not None:
resp.join_code.creator = creators[m.join_code.created_by]

Expand Down Expand Up @@ -272,9 +272,8 @@ def list_memberships(
config,
surface,
gate_age_flags(membership, resp.model_dump(mode="json", exclude=exclude)),
tournament,
)
for membership, resp in zip(memberships, responses)
for membership, resp in zip(memberships, responses, strict=True)
]
return JSONResponse(data)

Expand Down Expand Up @@ -419,7 +418,7 @@ def get_membership(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission(MANAGE_MEMBERS)),
):
tournament = get_tournament(tournament_id, db)
get_tournament(tournament_id, db)
m = get_scoped_or_404(db, TournamentMembership, membership_id, tournament_id, "Membership")

config = viewer_display_config(db, tournament_id, current_user.id)
Expand All @@ -441,7 +440,7 @@ def get_membership(
if wants(requested, ONBOARDING):
resp.onboarding = onboarding_reads(db, tournament_id, [m])[0]
data = gate_age_flags(m, resp.model_dump(mode="json", exclude=dump_exclude(requested)))
data = apply_display_config(config, surface, data, tournament)
data = apply_display_config(config, surface, data)
return JSONResponse(data)


Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/routes/tournament/shifts.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def create_shift(
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A shift named '{payload.label}' already exists on '{track.name}'",
)
) from None
return shift


Expand Down Expand Up @@ -223,7 +223,7 @@ def update_shift(
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A shift named '{shift.label}' already exists on '{track.name}'",
)
) from None
return shift


Expand Down
Loading
Loading