diff --git a/.githooks/pre-commit b/.githooks/pre-commit index d5e58822..9170392e 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -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 @@ -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 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 490eb8bf..64c5b800 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c05f6ee..e307d83f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) diff --git a/backend/alembic/versions/eb2e5d683865_tracks_own_the_schedule.py b/backend/alembic/versions/eb2e5d683865_tracks_own_the_schedule.py index b0750f11..90e699ff 100644 --- a/backend/alembic/versions/eb2e5d683865_tracks_own_the_schedule.py +++ b/backend/alembic/versions/eb2e5d683865_tracks_own_the_schedule.py @@ -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" diff --git a/backend/app/api/routes/events.py b/backend/app/api/routes/events.py index 2afbd481..1c6f7e2a 100644 --- a/backend/app/api/routes/events.py +++ b/backend/app/api/routes/events.py @@ -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 # --------------------------------------------------------------------------- @@ -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 diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index b6207dcb..8d550bef 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -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 @@ -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 @@ -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 diff --git a/backend/app/api/routes/join.py b/backend/app/api/routes/join.py index 357ae41f..a50ae2aa 100644 --- a/backend/app/api/routes/join.py +++ b/backend/app/api/routes/join.py @@ -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), ): @@ -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 @@ -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 diff --git a/backend/app/api/routes/season_event.py b/backend/app/api/routes/season_event.py index ca302855..609e197e 100644 --- a/backend/app/api/routes/season_event.py +++ b/backend/app/api/routes/season_event.py @@ -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 @@ -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 diff --git a/backend/app/api/routes/sheets.py b/backend/app/api/routes/sheets.py index 2a9126cb..e9c00d84 100644 --- a/backend/app/api/routes/sheets.py +++ b/backend/app/api/routes/sheets.py @@ -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 # --------------------------------------------------------------------------- @@ -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( @@ -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 # --------------------------------------------------------------------------- @@ -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 diff --git a/backend/app/api/routes/tournament/__init__.py b/backend/app/api/routes/tournament/__init__.py index 7cdd7518..26b42416 100644 --- a/backend/app/api/routes/tournament/__init__.py +++ b/backend/app/api/routes/tournament/__init__.py @@ -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 @@ -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. @@ -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) diff --git a/backend/app/api/routes/tournament/assignments.py b/backend/app/api/routes/tournament/assignments.py index a3482d00..d7b09cc3 100644 --- a/backend/app/api/routes/tournament/assignments.py +++ b/backend/app/api/routes/tournament/assignments.py @@ -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 # --------------------------------------------------------------------------- diff --git a/backend/app/api/routes/tournament/buildings.py b/backend/app/api/routes/tournament/buildings.py index 08f4c2b0..02659df4 100644 --- a/backend/app/api/routes/tournament/buildings.py +++ b/backend/app/api/routes/tournament/buildings.py @@ -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) @@ -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) diff --git a/backend/app/api/routes/tournament/events.py b/backend/app/api/routes/tournament/events.py index ee51e8d1..3e7a347a 100644 --- a/backend/app/api/routes/tournament/events.py +++ b/backend/app/api/routes/tournament/events.py @@ -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 @@ -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, @@ -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 @@ -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 diff --git a/backend/app/api/routes/tournament/export_presets.py b/backend/app/api/routes/tournament/export_presets.py index 216d66e9..f91235e1 100644 --- a/backend/app/api/routes/tournament/export_presets.py +++ b/backend/app/api/routes/tournament/export_presets.py @@ -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: @@ -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 # --------------------------------------------------------------------------- diff --git a/backend/app/api/routes/tournament/memberships.py b/backend/app/api/routes/tournament/memberships.py index d6256dac..a3dea331 100644 --- a/backend/app/api/routes/tournament/memberships.py +++ b/backend/app/api/routes/tournament/memberships.py @@ -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] @@ -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) @@ -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) @@ -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) diff --git a/backend/app/api/routes/tournament/shifts.py b/backend/app/api/routes/tournament/shifts.py index 5d7e40a2..9feb1df7 100644 --- a/backend/app/api/routes/tournament/shifts.py +++ b/backend/app/api/routes/tournament/shifts.py @@ -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 @@ -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 diff --git a/backend/app/api/routes/tournament/tracks.py b/backend/app/api/routes/tournament/tracks.py index 4c118511..7c864195 100644 --- a/backend/app/api/routes/tournament/tracks.py +++ b/backend/app/api/routes/tournament/tracks.py @@ -61,7 +61,7 @@ def _validate_state(track: TournamentTrack) -> None: try: require_primary_fields(track) except ValueError 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 # --------------------------------------------------------------------------- @@ -116,10 +116,10 @@ def create_track( db.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A track with this name already exists" - ) + ) from None except ValueError as e: db.rollback() - 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 track @@ -191,10 +191,10 @@ def update_track( db.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A track with this name already exists" - ) + ) from None except ValueError as e: db.rollback() - 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 track diff --git a/backend/app/api/routes/tournament/zones.py b/backend/app/api/routes/tournament/zones.py index a2135959..4bc2e642 100644 --- a/backend/app/api/routes/tournament/zones.py +++ b/backend/app/api/routes/tournament/zones.py @@ -241,7 +241,7 @@ def create_zone( raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A zone with this name already exists on this track", - ) + ) from error _claimed_conflict(db) db.refresh(zone) @@ -287,7 +287,7 @@ def update_zone( raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A zone with this name already exists on this track", - ) + ) from error _claimed_conflict(db) db.refresh(zone) @@ -407,7 +407,7 @@ def create_zone_assignment( raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="That member already covers this zone in that role", - ) + ) from None db.refresh(assignment) return ZoneAssignmentRead.from_row(assignment) diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index 57a02dbf..72d93a68 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -10,6 +10,7 @@ TournamentShift) and reserved field_key pairing.""" import re +from functools import partial from pydantic import ValidationError from sqlalchemy.orm import Session @@ -107,7 +108,7 @@ def validate_field_config(question_type: str, config: dict | None) -> dict: try: parsed = schema_cls.model_validate(config or {}) except ValidationError as e: - raise FormFieldValidationError(str(e)) + raise FormFieldValidationError(str(e)) from e normalized = parsed.model_dump() # These fields were added for track statuses after forms already existed. @@ -345,14 +346,24 @@ def collect_active_field_errors(db: Session, form: Form) -> list[str]: continue for check in ( - lambda: validate_reserved_field_key(field.field_key, field.question_type), - lambda: validate_tournament_preset(field.field_key, form.tournament_id), - lambda: validate_preset_track(db, form.tournament_id, field.field_key), - lambda: validate_track_status_options( - db, form.tournament_id, field.field_key, field.question_type, normalized_config + partial(validate_reserved_field_key, field.field_key, field.question_type), + partial(validate_tournament_preset, field.field_key, form.tournament_id), + partial(validate_preset_track, db, form.tournament_id, field.field_key), + partial( + validate_track_status_options, + db, + form.tournament_id, + field.field_key, + field.question_type, + normalized_config, ), - lambda: validate_branching_options( - db, form.id, field.question_type, normalized_config, field_id=field.id + partial( + validate_branching_options, + db, + form.id, + field.question_type, + normalized_config, + field_id=field.id, ), ): try: diff --git a/backend/app/core/tournament/__init__.py b/backend/app/core/tournament/__init__.py index 424a7909..21ebd064 100644 --- a/backend/app/core/tournament/__init__.py +++ b/backend/app/core/tournament/__init__.py @@ -2,19 +2,15 @@ from collections.abc import Sequence from datetime import datetime -from typing import TypeVar from zoneinfo import ZoneInfo from fastapi import Depends, HTTPException, status from sqlalchemy import func from sqlalchemy.orm import Session -from app.core.auth import get_current_user from app.core.tournament.memberships import ACTIVE_MEMBERSHIP_CLAUSE from app.db.session import get_db -from app.models.models import Tournament, TournamentEvent, TournamentMembership, User - -T = TypeVar("T") +from app.models.models import Tournament, TournamentEvent, TournamentMembership def tournament_local_date(tournament: Tournament, moment: datetime): @@ -27,7 +23,9 @@ def tournament_local_date(tournament: Tournament, moment: datetime): return moment.astimezone(ZoneInfo(tournament.timezone)).date() -def get_scoped_or_404(db: Session, model: type[T], id_: int, tournament_id: int, label: str) -> T: +def get_scoped_or_404[T]( + db: Session, model: type[T], id_: int, tournament_id: int, label: str +) -> T: """ Fetch a row by id, scoped to tournament_id via its own tournament_id column. 404s on either a missing row or a tournament mismatch — the two diff --git a/backend/app/core/tournament/display_config.py b/backend/app/core/tournament/display_config.py index 3f0c4944..064d85fb 100644 --- a/backend/app/core/tournament/display_config.py +++ b/backend/app/core/tournament/display_config.py @@ -869,13 +869,10 @@ def viewer_display_config(db, tournament_id: int, user_id: int) -> dict: return (membership.display_config or {}) if membership else {} -def apply_display_config(config: dict | None, surface: str | None, data: dict, tournament) -> dict: +def apply_display_config(config: dict | None, surface: str | None, data: dict) -> dict: """Drops hidden items from a serialized MembershipFullResponse dict for the given surface, per the *viewer's* config (see viewer_display_config) — not the tournament's, which no longer has one. - `tournament` is still needed even though the config no longer lives on - it: availability items key by tournament-local day, which only the - tournament's timezone can resolve. Hidden items are omitted from the payload itself — not left for the client to filter — so stale data never crosses the wire. `surface=None` @@ -910,9 +907,7 @@ def _note_if_emptied(key: str, before: list) -> None: ] if "availability" in data: before_availability = data["availability"] - # Keyed by the shift's track, which the row carries outright — no - # timezone conversion needed any more, unlike the day-keyed version - # this replaced. + # Keyed by the shift's track, which the row carries outright. data["availability"] = [ row for row in data["availability"] diff --git a/backend/app/core/tournament/memberships.py b/backend/app/core/tournament/memberships.py index 8625004b..c8cf5424 100644 --- a/backend/app/core/tournament/memberships.py +++ b/backend/app/core/tournament/memberships.py @@ -568,7 +568,9 @@ def enrich_built_groups( return if wants(requested, ONBOARDING): - for response, progress in zip(responses, onboarding_reads(db, tournament.id, memberships)): + for response, progress in zip( + responses, onboarding_reads(db, tournament.id, memberships), strict=True + ): response.onboarding = progress if wants(requested, LUNCH): @@ -581,7 +583,7 @@ def enrich_built_groups( for row in membership.lunch_selections } types = lunch_question_types(db, tournament.id, keys) - for membership, response in zip(memberships, responses): + for membership, response in zip(memberships, responses, strict=True): response.lunch = build_lunch_rows(membership.lunch_selections, types) if wants(requested, CUSTOM): @@ -590,7 +592,7 @@ def enrich_built_groups( tournament.id, [m.user_id for m in memberships], ) - for membership, response in zip(memberships, responses): + for membership, response in zip(memberships, responses, strict=True): response.custom_responses = by_user.get(membership.user_id, []) if wants(requested, EVENT_PREFS): @@ -598,5 +600,5 @@ def enrich_built_groups( # against the form options that produced it, and the reversal is # keyed by which questions that member actually answered. No client # asks a roster for this today; batch it if one starts. - for membership, response in zip(memberships, responses): + for membership, response in zip(memberships, responses, strict=True): response.event_preferences = build_event_preferences(db, membership) diff --git a/backend/app/schemas/chapter/__init__.py b/backend/app/schemas/chapter/__init__.py index cd942fa9..7bfeb0d6 100644 --- a/backend/app/schemas/chapter/__init__.py +++ b/backend/app/schemas/chapter/__init__.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Optional - from pydantic import BaseModel from app.schemas.university import UniversityResponse diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index eec53210..8e0c2362 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -167,12 +167,12 @@ async def send_signup_verification_email(db: Session, to: str, user_id: int) -> try: token = create_verification_token(db, user_id, "signup_verify") except RateLimitedError as e: - raise HTTPException(429, str(e)) + raise HTTPException(429, str(e)) from e try: await send_verification_email(to, token) - except Exception: - raise HTTPException(500, "Failed to send verification email") + except Exception as e: + raise HTTPException(500, "Failed to send verification email") from e # --------------------------------------------------------------------------- @@ -201,12 +201,12 @@ async def send_email_change_request_email(db: Session, user_id: int, new_email: try: token = create_verification_token(db, user_id, "email_change", new_email=new_email) except RateLimitedError as e: - raise HTTPException(429, str(e)) + raise HTTPException(429, str(e)) from e try: await send_email_change_email(new_email, token) - except Exception: - raise HTTPException(500, "Failed to send email change confirmation") + except Exception as e: + raise HTTPException(500, "Failed to send email change confirmation") from e async def send_email_change_requested_notice( @@ -228,7 +228,7 @@ async def send_email_change_requested_notice( db, user_id, "email_change_revert", new_email=old_email ) except RateLimitedError as e: - raise HTTPException(429, str(e)) + raise HTTPException(429, str(e)) from e url = _cta_url("/revert-email-change", revert_token) html = _render_email_html( @@ -273,12 +273,12 @@ async def send_password_reset_request_email(db: Session, user_id: int, to: str) try: token = create_verification_token(db, user_id, "password_reset") except RateLimitedError as e: - raise HTTPException(429, str(e)) + raise HTTPException(429, str(e)) from e try: await send_password_reset_email(to, token) - except Exception: - raise HTTPException(500, "Failed to send password reset email") + except Exception as e: + raise HTTPException(500, "Failed to send password reset email") from e async def send_password_changed_notice(to: str) -> None: @@ -330,12 +330,12 @@ async def send_account_setup_invite_email(db: Session, user_id: int, to: str) -> try: token = create_verification_token(db, user_id, "account_setup") except RateLimitedError as e: - raise HTTPException(429, str(e)) + raise HTTPException(429, str(e)) from e try: await send_account_setup_email(to, token) - except Exception: - raise HTTPException(500, "Failed to send account setup email") + except Exception as e: + raise HTTPException(500, "Failed to send account setup email") from e # --------------------------------------------------------------------------- @@ -377,4 +377,8 @@ async def send_staff_invite_emails( *(send_staff_invite_email(email, tournament_name, join_url) for email in to_emails), return_exceptions=True, ) - return [email for email, result in zip(to_emails, results) if isinstance(result, Exception)] + return [ + email + for email, result in zip(to_emails, results, strict=True) + if isinstance(result, Exception) + ] diff --git a/backend/app/services/forms_service.py b/backend/app/services/forms_service.py index efce1a3d..508da412 100644 --- a/backend/app/services/forms_service.py +++ b/backend/app/services/forms_service.py @@ -135,9 +135,9 @@ def get_form_questions(self, form_url: str) -> list[dict]: raise PermissionError( "Service account does not have access to this form. " "Share the form with the service account email." - ) + ) from e if e.resp.status == 404: - raise ValueError("Form not found. Check the URL.") + raise ValueError("Form not found. Check the URL.") from e raise questions: list[dict] = [] diff --git a/backend/app/services/sheets_service.py b/backend/app/services/sheets_service.py index f473a3a5..621c9bca 100644 --- a/backend/app/services/sheets_service.py +++ b/backend/app/services/sheets_service.py @@ -88,9 +88,9 @@ def validate_sheet_url(self, sheet_url: str) -> SheetValidateResponse: raise PermissionError( "Service account does not have access to this spreadsheet. " "Share the sheet with the service account email." - ) + ) from e if e.resp.status == 404: - raise ValueError("Spreadsheet not found. Check the URL.") + raise ValueError("Spreadsheet not found. Check the URL.") from e raise title = metadata.get("properties", {}).get("title", "Untitled") @@ -141,7 +141,7 @@ def _fetch_header_row(self, sheet_url: str, sheet_name: str) -> list[str]: ) except HttpError as e: if e.resp.status == 403: - raise PermissionError("Service account cannot read this sheet.") + raise PermissionError("Service account cannot read this sheet.") from e raise rows = result.get("values", []) return rows[0] if rows else [] @@ -157,7 +157,7 @@ def get_rows( result_dicts: list[dict[str, Any]] = [] for row in rows: padded = row + [""] * (len(headers) - len(row)) - result_dicts.append(dict(zip(headers, padded))) + result_dicts.append(dict(zip(headers, padded, strict=False))) return result_dicts def get_rows_with_headers( @@ -173,7 +173,7 @@ def get_rows_with_headers( ) except HttpError as e: if e.resp.status == 403: - raise PermissionError("Service account cannot read this sheet.") + raise PermissionError("Service account cannot read this sheet.") from e raise rows = result.get("values", []) diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py index 1625d48b..64e34356 100644 --- a/backend/app/services/sync_service.py +++ b/backend/app/services/sync_service.py @@ -226,7 +226,7 @@ def _merge_availability(existing: list[dict], new_slots: list[dict]) -> list[dic by_date.setdefault(slot["date"], []).append(slot) merged = [] - for date, slots in sorted(by_date.items()): + for _date, slots in sorted(by_date.items()): slots.sort(key=lambda s: s["start"]) current = dict(slots[0]) for slot in slots[1:]: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index dc98b5a4..4b41cb32 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -50,4 +50,13 @@ known-third-party = ["alembic"] [tool.ruff.lint.flake8-bugbear] # FastAPI's dependency markers are meant to be called in defaults. -extend-immutable-calls = ["fastapi.Depends", "fastapi.Query", "fastapi.Body", "fastapi.Path"] +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.Query", + "fastapi.Body", + "fastapi.Path", + "app.core.tournament.permissions.require_membership", + "app.core.tournament.permissions.require_permission", + "app.core.tournament.permissions.require_any_permission", + "app.core.tournament.permissions.require_catalog_read", +] diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index f7fa49dd..63d609c8 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -4299,7 +4299,7 @@ def test_submission_rejected_when_reachable_required_field_missing( self, client, db, td_user, td_tournament ): form = _make_form(db, td_user, td_tournament, status="published") - required_field = _make_field( + _make_field( db, form, order=1, @@ -4317,7 +4317,7 @@ def test_submission_accepted_when_branch_skips_required_field( self, client, db, td_user, td_tournament ): form = _make_form(db, td_user, td_tournament, status="published") - skipped = _make_field( + _make_field( db, form, order=2, diff --git a/backend/tests/api/tournament/test_memberships.py b/backend/tests/api/tournament/test_memberships.py index 7b6e4239..71eff770 100644 --- a/backend/tests/api/tournament/test_memberships.py +++ b/backend/tests/api/tournament/test_memberships.py @@ -1837,11 +1837,8 @@ def test_get_membership_surface_hides_custom_response(client, td_user, td_tourna assert response.json()["custom_responses"] == [] -def test_get_membership_surface_hides_availability_day(client, td_user, td_tournament, db): - """Availability items key by tournament-local day, so this is the one - hidden namespace that needs the tournament's timezone to resolve — the - path that had no coverage when apply_display_config lost that argument.""" - from app.core.tournament import tournament_local_date +def test_get_membership_surface_hides_availability_track(client, td_user, td_tournament, db): + """Hiding a track's availability drops its rows from the detail read.""" from app.models.models import TournamentMembershipAvailability u = _make_user(db) @@ -1849,7 +1846,6 @@ def test_get_membership_surface_hides_availability_day(client, td_user, td_tourn shift = _make_shift(db, td_tournament.id, "Morning", day=1) db.add(TournamentMembershipAvailability(membership_id=m.id, tournament_shift_id=shift.id)) db.commit() - day = tournament_local_date(td_tournament, shift.start).isoformat() _set_display_config( db, td_tournament, @@ -2583,7 +2579,7 @@ def test_picking_a_group_again_re_opts_in(client, td_user, td_tournament, db): def test_my_options_groups_questions_by_track(client, td_user, td_tournament, db): track_id = primary_track_id(db, td_tournament.id) - writing = _make_track(db, td_tournament.id, "Test Writing") + _make_track(db, td_tournament.id, "Test Writing") _lunch_field( db, td_user, diff --git a/backend/tests/api/tournament/test_zones.py b/backend/tests/api/tournament/test_zones.py index 27c84013..f08e6f20 100644 --- a/backend/tests/api/tournament/test_zones.py +++ b/backend/tests/api/tournament/test_zones.py @@ -155,7 +155,7 @@ def test_moving_an_event_changes_contents_with_no_zone_edit( steinhaus = _building(client, td_tournament.id, [track], name="Steinhaus") anatomy = _event(client, td_tournament.id, track, "Anatomy", rowland["id"], "2") - north = _zone( + _zone( client, td_tournament.id, track, @@ -163,8 +163,8 @@ def test_moving_an_event_changes_contents_with_no_zone_edit( members=[ {"kind": "building", "building_id": rowland["id"]}, ], - ).json() - south = _zone( + ) + _zone( client, td_tournament.id, track, @@ -172,7 +172,7 @@ def test_moving_an_event_changes_contents_with_no_zone_edit( members=[ {"kind": "building", "building_id": steinhaus["id"]}, ], - ).json() + ) assert _coverage(client, td_tournament.id, track)["zones"][0]["event_ids"] == [anatomy["id"]] client.patch(