The Fast Fixture-Entry UI Outside the Admin: Click-to-Pair Teams Into a Gameweek

Premier League Predictor: Django & MySQL

Chapter 4 · The Fast Fixture-Entry UI Outside the Admin: Click-to-Pair Teams Into a Gameweek

Chapter 3's own admin inline genuinely works, but means two searches per fixture, ten times a week. This chapter builds the real, faster alternative: 20 always-visible team buttons, clicked directly into place — the same underlying Fixture data, a purpose-built interface on top.

Rendering the Team Grid Server-Side, Not Fetched

plpredict-fastapi1's own vanilla-JS frontend always fetched the season's teams from a JSON route the moment the page loaded. Django's own template engine can do that job directly, server-side, before the page is even sent to the browser:

# predictor/views.py from django.shortcuts import render, get_object_or_404 from django.views.decorators.csrf import ensure_csrf_cookie from .models import Gameweek, SeasonTeam @ensure_csrf_cookie def fixture_entry(request, gameweek_id): gameweek = get_object_or_404(Gameweek, pk=gameweek_id) season_teams = ( SeasonTeam.objects .filter(season=gameweek.season) .select_related('team') .order_by('team__name') ) teams = [st.team for st in season_teams] return render(request, 'predictor/fixture_entry.html', { 'gameweek': gameweek, 'teams': teams, })
predictor/templates/predictor/fixture_entry.html
<div id="team-grid" class="team-grid"> {% for team in teams %} <button type="button" data-team-id="{{ team.id }}">{{ team.short_name }}</button> {% endfor %} </div> <div class="fixture-slots"> <div id="home-slot" class="slot">Home</div> <span>vs</span> <div id="away-slot" class="slot">Away</div> </div> <button type="button" onclick="clearSelection()">Clear</button> <button type="button" id="add-fixture-btn" onclick="addFixture()" disabled>Add Fixture</button>
One real fetch call this course never needs
{% for team in teams %} writes every button directly into the HTML the server sends — by the time a browser paints this page, the 20 team buttons already exist, with no separate JavaScript request needed just to populate them. The FastAPI/vanilla-JS sibling's own loadTeams() function exists specifically because that course has no template engine standing between its data and its HTML; here, that same job is Django's own template rendering, done once, server-side, before the response is even sent.

The Real New Concern: CSRF

Adding a fixture is still a real state-changing request — this page's own JavaScript needs to POST to a Django view. That's the first genuinely new requirement this course introduces that its FastAPI sibling never had to think about at all:

FastAPI ships no CSRF protection by default; Django's is on unless deliberately removed
plpredict-fastapi1's own fixture-entry chapter never mentions CSRF, because FastAPI has no built-in cross-site request forgery protection at all — nothing stops a POST from succeeding regardless of where it originated. Django is the opposite default: CsrfViewMiddleware, present in every fresh Django project's own MIDDLEWARE setting, rejects any POST/PUT/PATCH/DELETE request that doesn't include a valid CSRF token — including a JavaScript fetch() call, exactly like the one this chapter's own Add Fixture button needs to make.

@ensure_csrf_cookie on the view above guarantees the browser actually has a real CSRF token to send, even though this page renders no traditional HTML <form> for Django to attach one to automatically. The token then has to be read from the cookie and included on every fetch call by hand:

// static/predictor/fixtures.js function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); } const csrftoken = getCookie('csrftoken');

Creating a Fixture: A Real JSON View, With the Same Server-Side Guard

# predictor/views.py (additions) import json from django.http import JsonResponse from django.views.decorators.http import require_POST from .models import Fixture @require_POST def create_fixture(request, gameweek_id): gameweek = get_object_or_404(Gameweek, pk=gameweek_id) payload = json.loads(request.body) home_team_id = payload.get('home_team_id') away_team_id = payload.get('away_team_id') if home_team_id == away_team_id: return JsonResponse({'error': 'A team cannot play itself'}, status=400) # The real authority: check every fixture already entered this gameweek existing = Fixture.objects.filter(gameweek=gameweek) 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 home_team_id in used_team_ids or away_team_id in used_team_ids: return JsonResponse( {'error': 'One of these teams is already fixtured this gameweek'}, status=409 ) fixture = Fixture.objects.create( gameweek=gameweek, home_team_id=home_team_id, away_team_id=away_team_id ) return JsonResponse({'id': fixture.id})

@require_POST is Django's own small, real decorator for rejecting any other HTTP method with a clean 405 — no need for a separate view per method the way a bare function view would otherwise need to branch on request.method by hand.

// static/predictor/fixtures.js (continued) let selectedHome = null; let selectedAway = null; let usedTeamIds = new Set(); document.querySelectorAll('#team-grid button').forEach(btn => { btn.addEventListener('click', () => selectTeam(btn)); }); function selectTeam(btn) { const teamId = Number(btn.dataset.teamId); if (usedTeamIds.has(teamId)) return; if (!selectedHome) { selectedHome = { id: teamId, label: btn.textContent }; document.getElementById('home-slot').textContent = btn.textContent; } else if (!selectedAway && teamId !== selectedHome.id) { selectedAway = { id: teamId, label: btn.textContent }; document.getElementById('away-slot').textContent = btn.textContent; } 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 gameweekId = document.body.dataset.gameweekId; const res = await fetch(`/gameweeks/${gameweekId}/fixtures/create/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken, }, body: JSON.stringify({ home_team_id: selectedHome.id, away_team_id: selectedAway.id, }), }); if (!res.ok) { const error = await res.json(); alert(error.error); 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(); }
The X-CSRFToken header is required, not optional — leaving it out fails loudly
Without 'X-CSRFToken': csrftoken on the fetch call, Django's own CsrfViewMiddleware rejects the request outright with a real 403 Forbidden, before create_fixture's own code ever runs — a genuinely different failure mode from the duplicate-team 409 above, and a real, common early mistake when adding AJAX to an otherwise server-rendered Django page for the first time.
The same client-vs-server authority distinction as the FastAPI sibling, still true here
usedTeamIds starts empty on every page load, seeded only by fixtures actually added during the current visit — a stale reload could show a team as available that's genuinely already fixtured. create_fixture's own server-side check, querying every real Fixture row for the gameweek, is what actually prevents a bad duplicate; the client-side disabling above is convenience, exactly the same honest distinction plpredict-fastapi1's own Chapter 4 already established.

Where This Course Is Headed

Recording all four prediction sources against each fixture this chapter creates, and the real MySQL-specific answer to the partial-unique-index gap flagged back in Chapter 1 (Chapter 5); entering results (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, plus styling, to replace this chapter's own single-gameweek page (Chapter 10).

Hands-On Exercises

Exercise 1

Explain why fixture_entry never needs a JavaScript fetch call just to display the initial 20 team buttons, tracing the reason back to what Django's template engine does that plpredict-fastapi1's own vanilla-JS frontend has no equivalent for.

📄 View solution
Exercise 2

Explain why @ensure_csrf_cookie is needed on fixture_entry even though that view renders no traditional HTML form, and what would happen to the Add Fixture button's own fetch call if the X-CSRFToken header were left out entirely.

📄 View solution
Exercise 3

Build the full click-to-pair page yourself against a real gameweek, enter at least 3 fixtures, then deliberately remove the X-CSRFToken header from addFixture() and confirm the real, different error you get back compared to attempting to reuse an already-fixtured team.

📄 View solution

Chapter 4 Quick Reference

  • Team grid — rendered server-side via {% for team in teams %}, no client-side fetch needed to populate it, unlike the FastAPI sibling
  • @ensure_csrf_cookie — guarantees a CSRF token exists for a page with no traditional form to attach one to automatically
  • CSRF — a genuinely new concern this course introduces; FastAPI ships none of this by default, Django's CsrfViewMiddleware rejects an unprotected POST outright
  • X-CSRFToken header — required on every fetch() that changes state; missing it fails with a real 403, a different failure mode from the duplicate-team 409
  • @require_POST — Django's own small decorator for a POST-only view, no manual request.method branching
  • Same real authority split as the FastAPI sibling — server-side duplicate-team check is the real rule; client-side disabling is convenience
  • Next chapter: Predictions — and a genuinely MySQL-specific answer to Chapter 1's own flagged gap