Entering Results & Calculating Correct Score vs. Correct Result

Premier League Predictor: Django & MySQL

Chapter 6 · Entering Results & Calculating Correct Score vs. Correct Result

Every fixture since Chapter 4 has sat with home_score/away_score null. Every prediction since Chapter 5 has sat with points_awarded null too — a field that chapter already added to the model in anticipation of this one. This chapter fills in the real result and, at the same moment, scores everything predicted against it — with real, confirmed point values: 40 points for a correct score, 10 points for a correct result.

A Pure Scoring Function, Shared by Every Source

User, expert, AI, and every individual guest are all scored by the exact same rule — there's no special case per source, because the rule genuinely doesn't need one. The logic itself has nothing to do with Django or MySQL at all; it's plain Python, sitting in its own module:

# predictor/scoring.py POINTS_CORRECT_SCORE = 40 POINTS_CORRECT_RESULT = 10 def _outcome(home, away): if home > away: return 'home_win' elif away > home: return 'away_win' return 'draw' def score_prediction(predicted_home, predicted_away, actual_home, actual_away): if predicted_home == actual_home and predicted_away == actual_away: return POINTS_CORRECT_SCORE if _outcome(predicted_home, predicted_away) == _outcome(actual_home, actual_away): return POINTS_CORRECT_RESULT return 0

The exact-match check runs first, deliberately: every correct score is automatically a correct result too, since the outcome is derived straight from the scoreline — but a correct result doesn't imply a correct score in the other direction. Checking the stronger condition first and returning immediately is what stops a correctly predicted 2-1 from being scored as a mere 10-point correct result instead of the full 40.

Entering a Result: One POST, Then a Real Batched Rescore

A small POST view, matching the @require_POST convention already established for every mutation in this course since Chapter 4:

# predictor/views.py (additions) from .scoring import score_prediction @require_POST def enter_result(request, fixture_id): fixture = get_object_or_404(Fixture, pk=fixture_id) payload = json.loads(request.body) home_score = payload.get('home_score') away_score = payload.get('away_score') if home_score is None or away_score is None: return JsonResponse( {'error': 'home_score and away_score are both required'}, status=400 ) fixture.home_score = home_score fixture.away_score = away_score fixture.status = Fixture.STATUS_PLAYED fixture.save() predictions = list(Prediction.objects.filter(fixture=fixture)) for prediction in predictions: prediction.points_awarded = score_prediction( prediction.predicted_home_score, prediction.predicted_away_score, home_score, away_score, ) Prediction.objects.bulk_update(predictions, ['points_awarded']) return JsonResponse({ 'id': fixture.id, 'home_score': fixture.home_score, 'away_score': fixture.away_score, 'status': fixture.status, })
A real Django ORM built-in for updating many rows in one pass
A naive version of this loop would call prediction.save() inside the for loop itself — one individual UPDATE statement per prediction, N round trips to MySQL for N predictions. Prediction.objects.bulk_update(predictions, ['points_awarded']) instead collapses that into far fewer queries: internally, Django batches the objects (100 per batch by default) and compiles each batch into a single real SQL statement built around a CASE WHEN id = ... THEN ... END expression, updating every row in the batch in one round trip rather than one per row — the same category of "gets this for free from the ORM" payoff update_or_create() already delivered in Chapter 5, applied here to bulk rescoring instead of a single upsert.
bulk_update() skips save() entirely — no signals fire
Django's own documentation is explicit about this: bulk_update() never calls each object's save() method, and it sends no pre_save/post_save signals. That's genuinely fine here — nothing in this app currently listens for those signals on Prediction — but it's a real, honest caveat worth knowing before reaching for bulk_update() in a codebase that does hang logic off save signals, since that logic would silently never run for a bulk-updated row.
Rescoring on correction is a feature, not a side effect
enter_result re-fetches and recomputes points_awarded for every prediction on the fixture every single time it's called — not just the first time. Fixing a mistyped scoreline genuinely re-scores every user, expert, guest, and AI prediction correctly against the corrected result, rather than leaving stale points sitting in the database from the wrong one. A fixture with zero predictions recorded simply loops over an empty list and calls bulk_update() with nothing to update — no special case needed.

The points_awarded Field Was Already Waiting

Chapter 5 added points_awarded to the Prediction model specifically for this chapter — models.PositiveSmallIntegerField(null=True, blank=True), null until a real result exists. Chapter 5's own list_predictions view already selects it too, in its .values('id', 'source', 'guest_name', 'predicted_home_score', 'predicted_away_score', 'points_awarded') call — nothing about that view needs to change now that the field actually has values in it.

Resolving Chapter 5's Deferred Question: Average the Points, Not the Scorelines

Chapter 5 left one thing open: multiple guests predicting the same fixture produce multiple real scorelines that don't average into another valid scoreline — 3-0 and 1-0 average to 2-0, which happens to look plausible by coincidence, but 3-0 and 0-2 average to "1.5-1," which isn't a result anything could actually finish.

Points are just numbers — they always average cleanly
Every guest's own Prediction row now gets a real points_awarded from the identical score_prediction() function used for user, expert, and AI — 40, 10, or 0, same as anyone else. A fixture with three guests that week now has three separate, real point values. Averaging those three numbers is completely well-defined, in a way averaging their three original scorelines never could be. Chapter 8's own prediction league table is where this average actually gets computed and folded into the guest predictor's own season total — one averaged figure per fixture, deliberately, so a fixture with three guests doesn't count three times as much toward the season total as a fixture with only one.

A Worked Example: Five Predictions Against a Real 2-1 Result

Say fixture 42 already has five real predictions recorded from Chapter 5 — user, expert, two separately-named guests, and AI. enter_result is called with {"home_score": 2, "away_score": 1}, and GET /fixtures/42/predictions/ comes back:

SourcePredictedOutcomepoints_awarded
user2-1exact match40
expert1-1draw vs. real home win0
guest — Micah Richards3-0home win, right outcome10
guest — Jamie Carragher1-0home win, right outcome10
ai2-0home win, right outcome10

The user's exact 2-1 earns the full 40. The expert's 1-1 is a draw — a different outcome from the real home win, so it earns nothing despite being close on paper. Both guests and the AI predicted a home win without the exact score, so each earns 10. The guest average for this fixture is therefore (10 + 10) / 2 = 10, the figure Chapter 8 would fold into the guest predictor's own season total for this one fixture.

Recording a Result From the Frontend

A small form, reusing the same csrftoken established in Chapter 4:

// static/predictor/results.js async function submitResult(fixtureId, homeScore, awayScore) { const res = await fetch(`/fixtures/${fixtureId}/result/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken, }, body: JSON.stringify({ home_score: Number(homeScore), away_score: Number(awayScore), }), }); if (!res.ok) { const error = await res.json(); alert(error.error); return; } alert('Result recorded — every prediction on this fixture has been rescored.'); }

Where This Course Is Headed

The real league table — points, goal difference, wins/draws/losses, built from every fixture this chapter has now filled in a real result for (Chapter 7); the prediction league table, aggregating every source's own points_awarded across the season, including the guest-averaging-by-fixture this chapter just resolved (Chapter 8); and promotion/relegation, operating on SeasonTeam from Chapter 2 (Chapter 9).

Hands-On Exercises

Exercise 1

Explain why score_prediction checks for an exact scoreline match before checking the outcome, and what would go wrong (in terms of points actually awarded) if the outcome check ran first instead.

📄 View solution
Exercise 2

Explain why this chapter resolves the guest-averaging problem by averaging each guest's own points rather than averaging their raw predicted scorelines, using a concrete example of two guest scorelines that cannot be meaningfully averaged directly.

📄 View solution
Exercise 3

Enter an incorrect result for a real fixture with at least two predictions already recorded via enter_result, confirm their points_awarded values, then call enter_result again with the corrected result and confirm every prediction's points_awarded genuinely changed via bulk_update() rather than only being scored the first time.

📄 View solution

Chapter 6 Quick Reference

  • Real point values (confirmed) — 40 points for a correct score, 10 points for a correct result, 0 otherwise
  • score_prediction() — one pure function, shared by every source, exact-match checked before outcome
  • enter_result — a POST view filling in the real scoreline, flipping status to "played," and rescoring every prediction on the fixture from scratch
  • bulk_update() — a real Django ORM built-in batching many row updates into a single CASE WHEN statement, instead of one save() per row
  • Honest caveat — bulk_update() skips save() and fires no pre_save/post_save signals
  • Correcting a result — genuinely re-scores everything, not just the first entry
  • Guest averaging, resolved — average each guest's own points per fixture, not their raw scorelines, which don't average into anything valid
  • Next chapter: The real league table — points, goal difference, wins/draws/losses