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:
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
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
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.
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.
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
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
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 solutionExplain 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 solutionComplete 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 solutionChapter 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