The Fast Fixture-Entry UI: Click-to-Pair Teams Into a Gameweek
Premier League Predictor: FastAPI & PostgreSQL
Chapter 4 · The Fast Fixture-Entry UI: Click-to-Pair Teams Into a Gameweek
Ten fixtures, every single gameweek, for 38 gameweeks — filling that in through two dropdowns per fixture would be genuinely tedious. This chapter builds the real alternative promised back in Chapter 1: a plain vanilla-JS/HTML/CSS frontend (served as static files, no build step — the same choice this site's own FastAPI courses already make when there's no React sibling forcing a different pick) with 20 always-visible team buttons, clicked directly into place.
Creating a Gameweek
# schemas.py (additions)class GameweekCreate(BaseModel):
number: intclass GameweekResponse(BaseModel):
id: int
season_id: int
number: intclass Config:
from_attributes = True
# routers/gameweeks.pyfrom fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from database import get_db
import models, schemas
router = APIRouter(prefix="/api", tags=["gameweeks"])
@router.post("/seasons/{season_id}/gameweeks", response_model=schemas.GameweekResponse)
def create_gameweek(season_id: int, payload: schemas.GameweekCreate, db: Session = Depends(get_db)):
season = db.get(models.Season, season_id)
if not season:
raise HTTPException(status_code=404, detail="Season not found")
if not (1 <= payload.number <= 38):
raise HTTPException(status_code=400, detail="Gameweek number must be between 1 and 38")
gameweek = models.Gameweek(season_id=season_id, number=payload.number)
db.add(gameweek)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=409, detail=f"Gameweek {payload.number} already exists for this season"
)
db.refresh(gameweek)
return gameweek
Turning Chapter 2's UniqueConstraint into a friendly error, not a crash
Chapter 2's own UniqueConstraint("season_id", "number") already stops a genuinely duplicate gameweek from ever being stored. Without the try/except above, PostgreSQL would still reject the duplicate — but as a raw IntegrityError bubbling straight up through FastAPI as an unhandled 500, with none of the useful "gameweek 12 already exists" detail this route actually returns. Catching it and calling db.rollback() — required, since a session that hit a failed commit() can't be reused until it's rolled back — turns a genuine database-level guarantee into a genuinely useful 409 response instead.
Creating a Fixture: The Real Server-Side Guard
# routers/fixtures.pyfrom fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
import models, schemas
router = APIRouter(prefix="/api", tags=["fixtures"])
@router.post("/gameweeks/{gameweek_id}/fixtures", response_model=schemas.FixtureResponse)
def create_fixture(gameweek_id: int, payload: schemas.FixtureCreate, db: Session = Depends(get_db)):
gameweek = db.get(models.Gameweek, gameweek_id)
if not gameweek:
raise HTTPException(status_code=404, detail="Gameweek not found")
if payload.home_team_id == payload.away_team_id:
raise HTTPException(status_code=400, detail="A team cannot play itself")
# The real authority: check every fixture already entered this gameweek
existing = db.query(models.Fixture).filter(models.Fixture.gameweek_id == gameweek_id).all()
used_team_ids = set()
for fx in existing:
used_team_ids.add(fx.home_team_id)
used_team_ids.add(fx.away_team_id)
if payload.home_team_id in used_team_ids or payload.away_team_id in used_team_ids:
raise HTTPException(
status_code=409, detail="One of these teams is already fixtured this gameweek"
)
fixture = models.Fixture(
gameweek_id=gameweek_id,
home_team_id=payload.home_team_id,
away_team_id=payload.away_team_id,
kickoff_time=payload.kickoff_time,
)
db.add(fixture)
db.commit()
db.refresh(fixture)
return fixture
@router.get("/gameweeks/{gameweek_id}/fixtures", response_model=list[schemas.FixtureResponse])
def list_fixtures(gameweek_id: int, db: Session = Depends(get_db)):
return (
db.query(models.Fixture)
.filter(models.Fixture.gameweek_id == gameweek_id)
.all()
)
schemas.FixtureCreate, added alongside the others:
class FixtureCreate(BaseModel):
home_team_id: int
away_team_id: int
kickoff_time: Optional[datetime] = None
This is the route Chapter 2's own warn-box was pointing at
Back in Chapter 2: "a team appearing twice in the same gameweek... this schema leaves it to the application layer instead." create_fixture above is that application layer — a real, explicit query across every existing fixture in the gameweek, not a database constraint. Checking home_team_id == away_team_id here as well, even though Chapter 2's CheckConstraint already blocks it at the database level, means a bad request gets a clean 400 with a real message instead of an unhandled IntegrityError — the exact same "friendly error over a raw crash" reasoning as the gameweek route above.
The Click-to-Pair Interface
The whole point: every one of the season's 20 teams is a button, always visible, always one click away — no scrolling through a dropdown to find a name.
// static/fixtures.js
// In the real app, seasonId and gameweekId come from Chapter 10's own
// gameweek/season selector — hardcoded here to keep this example focused.const seasonId = 1;
const gameweekId = 1;
let selectedHome = null;
let selectedAway = null;
let usedTeamIds = new Set();
async function loadTeams() {
const res = await fetch(`/api/seasons/${seasonId}/teams`);
const teams = await res.json();
const grid = document.getElementById('team-grid');
grid.innerHTML = '';
teams.forEach(team => {
const btn = document.createElement('button');
btn.textContent = team.short_name;
btn.dataset.teamId = team.id;
btn.addEventListener('click', () => selectTeam(team));
grid.appendChild(btn);
});
}
function selectTeam(team) {
if (usedTeamIds.has(team.id)) return;
if (!selectedHome) {
selectedHome = team;
document.getElementById('home-slot').textContent = team.short_name;
} else if (!selectedAway && team.id !== selectedHome.id) {
selectedAway = team;
document.getElementById('away-slot').textContent = team.short_name;
}
document.getElementById('add-fixture-btn').disabled = !(selectedHome && selectedAway);
}
function clearSelection() {
selectedHome = null;
selectedAway = null;
document.getElementById('home-slot').textContent = 'Home';
document.getElementById('away-slot').textContent = 'Away';
document.getElementById('add-fixture-btn').disabled = true;
}
async function addFixture() {
const res = await fetch(`/api/gameweeks/${gameweekId}/fixtures`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
home_team_id: selectedHome.id,
away_team_id: selectedAway.id,
}),
});
if (!res.ok) {
const error = await res.json();
alert(error.detail); // e.g. "One of these teams is already fixtured this gameweek"return;
}
usedTeamIds.add(selectedHome.id);
usedTeamIds.add(selectedAway.id);
document.querySelectorAll('#team-grid button').forEach(btn => {
if (usedTeamIds.has(Number(btn.dataset.teamId))) btn.disabled = true;
});
clearSelection();
}
loadTeams();
Clicking a team fills home-slot first, then away-slot — a two-click fixture, every time. Once both slots hold a team, Add Fixture enables; submitting it grays out both teams' own buttons for the rest of this gameweek, and resets the slots for the next pair.
Client-side disabling is convenience, not the real rule
usedTeamIds above lives only in the page's own memory — it starts empty every time the page loads, and only grows as fixtures are actually added during that visit. Reload the page halfway through entering a gameweek and every team button re-enables, even ones already fixtured. That's fine, because create_fixture's own server-side check (querying every existing fixture in the gameweek) is the real authority — a stale client would simply get a real 409 back rather than silently creating a bad duplicate. A more complete version would call GET /api/gameweeks/{id}/fixtures on page load and seed usedTeamIds from the real, current state, rather than assuming a fresh visit means a fresh gameweek — left out here to keep the example focused on the click-to-pair interaction itself.
Where This Course Is Headed
Recording all four prediction sources against each fixture this chapter creates (Chapter 5); entering results — the real UPDATE Chapter 2 set up, filling in the home_score/away_score this chapter's own fixtures still leave null (Chapter 6); the real league table (Chapter 7); the prediction league table (Chapter 8); promotion and relegation (Chapter 9); and a real gameweek/season selector to replace this chapter's own hardcoded seasonId/gameweekId (Chapter 10).
Hands-On Exercises
Exercise 1
Explain why create_gameweek and create_fixture both check their own rule in Python (the 1-38 range, home_team_id == away_team_id) even though a database-level guarantee (a CheckConstraint or a UniqueConstraint) already exists for each one.
Explain why usedTeamIds is described as "convenience, not the real rule," and describe exactly what happens — on both the client and the server — if a stale page somehow submits a fixture for a team that's already been used in that gameweek.
Build the full click-to-pair page yourself against a real season and gameweek, enter all 10 fixtures for a gameweek, and then write one sentence explaining what happens when you try to click an 11th team pairing that reuses an already-fixtured team.