Admin: Managing the 20 Competing Teams Each Season
Premier League Predictor: FastAPI & PostgreSQL
Chapter 3 · Admin: Managing the 20 Competing Teams Each Season
Chapter 2 built the schema; this chapter turns Season and SeasonTeam into real, working routes — the actual admin tooling used to set up a new season and manage which 20 teams are competing in it.
Bootstrapping a Season
Extending schemas.py with the Season shapes:
seasons table, not just the one being inserted — the same category of rule as Chapter 2's own "no team twice in a gameweek," which a single-row database constraint can't express on its own. The bulk update() above clears every other season's flag inside the same request, before the new season is even added — a deliberate, explicit app-level guarantee rather than something left to chance.
Adding a Team to a Season: Reuse or Create?
A promoted club might genuinely be a completely new name to this app, or it might be a club that was in the Premier League three seasons ago, got relegated, and is only now coming back up. Chapter 2's own design decision — Team rows persist forever, independent of any single season — means this route has to check which case it's actually in before deciding what to do:
flush() sends the pending INSERT to PostgreSQL and lets the database assign team.id — needed here, since SeasonTeam.team_id can't be set until an id actually exists — without ending the transaction. Both the new Team row and the new SeasonTeam row stay part of the same transaction until the final commit(), so if anything after the flush goes wrong, both roll back together rather than leaving an orphaned Team row with no SeasonTeam to go with it.
db.query(models.Team).filter(models.Team.name == payload.team_name) only reuses an existing team if the name matches character-for-character. Typing "Nottingham Forest" one season and "Nott'm Forest" the next creates a genuine duplicate Team row rather than reusing the real one — this route trusts the admin to type the name consistently rather than trying to guess a match. A dropdown of already-known team names (built from Chapter 4's own "20 clickable team buttons" pattern) would be a real, worthwhile fix, but isn't built in this chapter.
Removing a Team From a Season
This deletes the SeasonTeam row, not the Team itself — exactly the point of splitting the two tables back in Chapter 2. A relegated club's own historical Team row is untouched; only the fact "competing in this particular season" goes away.
Listing a Season's Teams
SeasonTeamResponse.team: TeamResponse, back up in the schema for the "add team" route, is a real Pydantic model nested inside another one. It works because SeasonTeam.team — the relationship("Team") defined in Chapter 2 — already gives a real Team object to read from; from_attributes = True lets Pydantic walk that relationship automatically and serialize the related team's own fields as a nested object in the JSON response, with no manual re-shaping of the query result required.
Where This Course Is Headed
The fast click-to-pair fixture-entry UI, built directly on top of this chapter's own GET /api/seasons/{season_id}/teams route to populate its 20 clickable team buttons (Chapter 4); recording predictions per fixture (Chapter 5); entering results (Chapter 6); both league tables (Chapters 7-8); and promotion/relegation, which reuses this chapter's own add/remove routes directly at the season boundary (Chapter 9).
Hands-On Exercises
Explain why create_season clears is_current on every other season with a bulk update before inserting the new one, and what would go wrong if that step were skipped.
📄 View solutionExplain why add_team_to_season looks up an existing Team by name before creating a new one, and describe a real scenario where skipping that lookup would create a duplicate Team row for the same real club.
📄 View solutionExplain why db.flush() is used instead of db.commit() when creating a brand-new Team inside add_team_to_season, and what real guarantee would be lost if commit() were used at that point instead.
📄 View solutionChapter 3 Quick Reference
- POST /api/seasons — creates a season; clears is_current on every other season first if the new one is marked current
- POST /api/seasons/{id}/teams — reuses an existing Team by name if one matches, otherwise creates one; enforces a 20-team cap via a COUNT check
- db.flush() vs. db.commit() — flush assigns an id without ending the transaction, keeping a new Team and its SeasonTeam row atomic together
- DELETE /api/seasons/{id}/teams/{team_id} — removes the SeasonTeam row only; the historical Team row is untouched
- GET /api/seasons/{id}/teams — a real join, returned through a nested SeasonTeamResponse/TeamResponse Pydantic model
- Real limit — team-name matching is exact, no fuzzy matching; no access control on any route in this chapter
- Next chapter: The fast click-to-pair fixture-entry UI