Promotion & Relegation: Auto-Calculating the Bottom Three, Manually Entering the Promoted Three

Premier League Predictor: FastAPI & PostgreSQL

Chapter 9 · Promotion & Relegation: Auto-Calculating the Bottom Three, Manually Entering the Promoted Three

Chapter 3's own tip-box previewed this chapter directly: promotion and relegation is really just more SeasonTeam writes, at a season boundary instead of mid-season. What's genuinely new here is where the bottom three come from — not a guess, not a manual entry, but Chapter 7's own real league table, reused as the actual source of truth.

Reusing Chapter 7's Table as the Source of Truth

Rather than duplicating the league-table SQL, Chapter 7's own route gets a small, honest refactor — its query logic pulled into a plain function both it and this chapter's new routes can call:

# routers/tables.py (refactored) def compute_league_table(db: Session, season_id: int) -> list[dict]: rows = db.execute(LEAGUE_TABLE_QUERY, {"season_id": season_id}).mappings().all() return [dict(row) for row in rows] @router.get("/seasons/{season_id}/table", response_model=list[schemas.LeagueTableRow]) def get_league_table(season_id: int, db: Session = Depends(get_db)): season = db.get(models.Season, season_id) if not season: raise HTTPException(status_code=404, detail="Season not found") return compute_league_table(db, season_id)

compute_league_table still runs the exact same ORDER BY points DESC, goal_difference DESC, goals_for DESC, teams.name ASC from Chapter 7 — which matters here more than it did there, since this chapter is about to trust that ordering to identify real relegation.

Previewing the Bottom Three Before Committing to Anything

# routers/rollover.py from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from database import get_db from routers.tables import compute_league_table import models, schemas router = APIRouter(prefix="/api", tags=["rollover"]) REQUIRED_TEAMS_PER_SEASON = 20 RELEGATION_COUNT = 3 @router.get("/seasons/{season_id}/relegation-preview", response_model=list[schemas.TeamResponse]) def preview_relegation(season_id: int, db: Session = Depends(get_db)): season = db.get(models.Season, season_id) if not season: raise HTTPException(status_code=404, detail="Season not found") table = compute_league_table(db, season_id) if len(table) != REQUIRED_TEAMS_PER_SEASON: raise HTTPException( status_code=400, detail=f"Season has {len(table)} teams, expected {REQUIRED_TEAMS_PER_SEASON}", ) bottom_three_ids = [row["team_id"] for row in table[-RELEGATION_COUNT:]] return ( db.query(models.Team) .filter(models.Team.id.in_(bottom_three_ids)) .all() )

A plain GET — nothing about calling it changes any data. Checking this before the real rollover is exactly how a mistake (a wrong result entered somewhere, silently shifting who's actually bottom three) gets caught before it's baked into next season's own competing set.

Rolling Over a Season

# schemas.py (additions) from pydantic import field_validator class PromotedTeam(BaseModel): team_name: str short_name: Optional[str] = None class RolloverRequest(BaseModel): promoted_teams: list[PromotedTeam] @field_validator("promoted_teams") @classmethod def exactly_three(cls, value): if len(value) != 3: raise ValueError("Exactly 3 promoted teams are required") return value
# routers/rollover.py (additions) @router.post("/seasons/{new_season_id}/rollover-from/{old_season_id}", response_model=list[schemas.TeamResponse]) def rollover_season( new_season_id: int, old_season_id: int, payload: schemas.RolloverRequest, db: Session = Depends(get_db) ): old_season = db.get(models.Season, old_season_id) new_season = db.get(models.Season, new_season_id) if not old_season or not new_season: raise HTTPException(status_code=404, detail="Season not found") existing_new_season_teams = ( db.query(models.SeasonTeam).filter(models.SeasonTeam.season_id == new_season_id).count() ) if existing_new_season_teams > 0: raise HTTPException( status_code=400, detail="New season already has teams; rollover expects a freshly created, empty season", ) old_table = compute_league_table(db, old_season_id) if len(old_table) != REQUIRED_TEAMS_PER_SEASON: raise HTTPException( status_code=400, detail=f"Old season has {len(old_table)} teams, expected {REQUIRED_TEAMS_PER_SEASON}", ) # old_table is already correctly ordered by compute_league_table's own real sort survivors = old_table[:-RELEGATION_COUNT] for row in survivors: db.add(models.SeasonTeam(season_id=new_season_id, team_id=row["team_id"])) for promoted in payload.promoted_teams: team = db.query(models.Team).filter(models.Team.name == promoted.team_name).first() if not team: team = models.Team(name=promoted.team_name, short_name=promoted.short_name or promoted.team_name) db.add(team) db.flush() db.add(models.SeasonTeam(season_id=new_season_id, team_id=team.id)) try: db.commit() except IntegrityError: db.rollback() raise HTTPException( status_code=409, detail="This rollover could not be completed cleanly — check whether it already ran", ) return ( db.query(models.Team) .join(models.SeasonTeam, models.SeasonTeam.team_id == models.Team.id) .filter(models.SeasonTeam.season_id == new_season_id) .order_by(models.Team.name) .all() )
"Removing" a relegated team doesn't delete anything
Nothing about this route touches old_season's own SeasonTeam rows at all — the relegated three keep their real, historical record of having competed in that season, exactly as Chapter 2's own "Team rows persist forever" design intended. "Removing" a relegated team means exactly one thing here: it simply never gets a matching SeasonTeam row created for new_season. survivors — every row in old_table except the bottom three — is what actually gets carried forward; the omission itself is the entire relegation mechanism.
The same friendly-IntegrityError pattern as Chapter 4, on a bigger operation
If this route is accidentally called twice for the same new_season_id, the second call would try to insert 20 SeasonTeam rows that already exist, colliding with Chapter 2's own UniqueConstraint("season_id", "team_id"). The try/except IntegrityError here is the exact same discipline Chapter 4 established for a single duplicate gameweek, just wrapping an operation that touches 20 rows instead of one — catching a real constraint violation and turning it into one clean 409 instead of a raw crash partway through.
No undo, and no partial-rollover recovery
If the promoted teams' own names are entered wrong, there's no dedicated "undo the rollover" route — fixing it means manually removing the incorrect SeasonTeam rows (Chapter 3's own DELETE /api/seasons/{id}/teams/{team_id}) and adding the correct ones by hand. This route also assumes the whole operation either fully succeeds or is caught cleanly by the IntegrityError case above — a real database failure partway through committing (rare, but possible) isn't specially handled beyond what PostgreSQL's own transaction already guarantees.

Using It From the Frontend

// static/rollover.js async function previewRelegation(seasonId) { const res = await fetch(`/api/seasons/${seasonId}/relegation-preview`); const teams = await res.json(); alert('Relegated: ' + teams.map(t => t.short_name).join(', ')); } async function rolloverSeason(newSeasonId, oldSeasonId, promotedTeams) { const res = await fetch(`/api/seasons/${newSeasonId}/rollover-from/${oldSeasonId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ promoted_teams: promotedTeams }), }); if (!res.ok) { const error = await res.json(); alert(error.detail); return; } const roster = await res.json(); alert(`New season roster: ${roster.length} teams`); }

Where This Course Is Headed

A real gameweek/season selector — replacing every hardcoded seasonId/gameweekId constant this course has used since Chapter 4 with a genuine dropdown backed by GET /api/seasons, tying every route built across the whole course into one working interface (Chapter 10); deployment (Chapter 11); and a capstone integrating the finished predictor into the existing Astro-based site (Chapter 12).

Hands-On Exercises

Exercise 1

Explain why survivors is computed as old_table[:-RELEGATION_COUNT] rather than by querying the database a second time for "every team except the bottom three," and what real guarantee this relies on from compute_league_table.

📄 View solution
Exercise 2

Explain what "removing" a relegated team from next season's SeasonTeam set actually means in this implementation, and confirm whether that team's own historical record in the old season is affected at all.

📄 View solution
Exercise 3

Complete a full real season (enter results for all fixtures needed to give every team a real record), call the relegation preview, create a new season, and roll it over with three real promoted team names — then confirm the new season's roster has exactly 20 teams: 17 real survivors plus the 3 promoted teams.

📄 View solution

Chapter 9 Quick Reference

  • compute_league_table() — Chapter 7's own logic, extracted so this chapter can reuse it as the real source of truth for relegation
  • GET /api/seasons/{id}/relegation-preview — a read-only sanity check before anything real is committed
  • POST /api/seasons/{new}/rollover-from/{old} — carries the surviving 17 teams forward, reuses Chapter 3's team-lookup pattern for the 3 promoted teams
  • "Removing" a relegated team — never deletes anything; the old season's own history is untouched, the team simply gets no SeasonTeam row in the new season
  • Precondition — the new season must start with zero SeasonTeam rows; the old season must have exactly 20
  • Real limit — no dedicated undo route; a double-call is caught by the same friendly-IntegrityError pattern from Chapter 4
  • Next chapter: A real gameweek/season selector, replacing every hardcoded ID this course has used since Chapter 4