Admin: Managing the 20 Competing Teams Each Season
Premier League Predictor: Astro
Chapter 3 · Admin: Managing the 20 Competing Teams Each Season
Chapter 2 built the schema; this chapter turns seasons and season_teams into real, working Astro API routes — the actual admin tooling used to set up a new season and manage which 20 teams are competing in it.
A Small JSON Response Helper, Reused From Here On
Every route from this chapter onward needs to return JSON with the right Content-Type header and status code. Rather than repeating that boilerplate in every single route, one small helper handles it once:
Bootstrapping a Season
seasons, 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 constraint can't express on its own. The plain UPDATE seasons SET is_current = 0 above clears every other season's flag before the new one is even inserted — 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 — teams 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:
db.flush() to get a new Team row's id assigned without ending the SQLAlchemy session, so it can build the related SeasonTeam row before the final commit() — both rows join or fail together as one real transaction. This stack has no session lifecycle to manage at all, so it reaches the same all-or-nothing guarantee differently: db.transaction(fn) wraps the entire inner function — the count check, the team lookup-or-create, the duplicate check, and the final insert — as one real SQLite transaction. If fn returns normally, everything inside it commits together; if it throws anywhere, SQLite automatically rolls back everything it did, and nothing partial is ever left behind. Same real atomicity, reached through wrapping a whole function rather than manually staging a flush partway through a longer-lived session.
HTTPException is caught by the framework itself and turned into a proper JSON error response with the right status code — raising it is enough. Astro's API routes have no equivalent built in: an uncaught thrown error inside a route handler becomes a generic, unstyled 500 response with no useful JSON body at all. That's exactly why the errors above are thrown as plain Error objects carrying a short string code ('SEASON_FULL', 'ALREADY_IN_SEASON') inside the transaction, then caught and explicitly translated into a real json({error: ...}, status) response outside it. Every route in this course follows the same shape: known failure cases are caught and turned into a deliberate response; anything genuinely unexpected is re-thrown and allowed to become a 500, since there's nothing sensible to say about it anyway.
db.refresh(db_season) re-populates a Python object's fields from the database after an insert. better-sqlite3 has no such object to refresh in the first place — result.lastInsertRowid gives the new row's real id directly off the .run() result, and a plain follow-up SELECT ... WHERE id = ? reads the row back exactly as it was actually stored, including any column defaults SQLite itself applied.
WHERE 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 teams 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
SELECT the SeasonTeam row first specifically to know whether it existed at all, before deciding whether to delete it or return a 404. better-sqlite3's own .run() result reports changes — the real number of rows the statement actually affected — so a single DELETE already answers both questions at once: if changes is 0, nothing matched, and the 404 is returned with no separate lookup query needed first.
This deletes the season_teams row, not the team itself — exactly the point of splitting the two tables back in Chapter 2. A relegated club's own historical teams row is untouched; only the fact "competing in this particular season" goes away.
Listing a Season's Teams
SeasonTeamResponse.team: TeamResponse Pydantic model to shape a joined query result into the right JSON structure. There's no equivalent step here at all — the raw SQL JOIN above already selects exactly the columns the response needs, in exactly the shape it needs them, and db.prepare(...).all() hands back plain JavaScript objects that json() serializes directly. Nothing is being reshaped between the query and the response, because the query was written to already produce the response's own shape.
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/{seasonId}/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 the POST /api/seasons route clears is_current on every other season with a plain UPDATE before inserting the new one, and what would go wrong if that step were skipped.
📄 View solutionExplain why the add-team route 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 teams row for the same real club.
📄 View solutionExplain why the team-lookup-or-create step and the season_teams insert are both wrapped inside a single db.transaction() call rather than run as two separate, un-wrapped statements, and what real guarantee would be lost if the wrapper were removed.
📄 View solutionChapter 3 Quick Reference
- json() helper — one small function wrapping every route's own JSON response and status code, reused from here on
- 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, all wrapped in db.transaction()
- db.transaction() vs. flush()/commit() — a whole function committed or rolled back together, replacing SQLAlchemy's own mid-session flush pattern
- No HTTPException equivalent — known errors are thrown as plain Error objects inside the transaction, then explicitly caught and translated into json({error}, status) responses, since an uncaught error would otherwise become a bare 500
- DELETE /api/seasons/{id}/teams/{teamId} — removes the season_teams row only; result.changes === 0 replaces a separate existence-check query
- GET /api/seasons/{id}/teams — a real JOIN, already shaped to match the response with no serialization step needed
- 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