Predictions: Recording the User, Expert, Guest(s) & AI Predictions Per Fixture
Premier League Predictor: Django & MySQL
Chapter 5 · Predictions: Recording the User, Expert, Guest(s) & AI Predictions Per Fixture
Before a fixture's real result exists, four real sources each predict what they think will happen. This chapter builds the one model that records all four — and finally resolves the real gap Chapter 1 flagged: MySQL has no equivalent to PostgreSQL's own partial unique index, and this chapter needs exactly that shape of rule.
One Model, Four Sources
MySQL's Own Real Workaround: A Generated Column Exploiting NULL's Own Uniqueness Rules
Three of the four sources genuinely predict exactly once per fixture; guest deliberately doesn't — some weeks have several. plpredict-fastapi1's own PostgreSQL sibling solved this with a partial unique index, WHERE source != 'guest'. MySQL has no such feature — it doesn't support a conditional clause on an index at all. The real, standard MySQL workaround exploits a different, universal SQL rule instead: a unique index never treats two NULL values as equal to each other, so any number of NULLs can coexist in a column a unique index otherwise enforces strictly.
unique_key above is a real, database-computed column — Django's own GeneratedField, added in Django 5.0 — that evaluates, for every row, to either NULL (for a guest prediction) or a real, deterministic string like "42-expert" (fixture id + source, for everyone else). The UniqueConstraint is placed on that generated column, not on fixture/source directly. It compiles to genuine MySQL syntax:
unique_key is always NULL — MySQL never rejects a second, third, or tenth NULL in a unique-indexed column, so any number of guest predictions coexist freely on the same fixture, exactly as intended. A user/expert/AI row's unique_key is a real, non-null string unique per (fixture, source) — the unique index rejects a genuine second attempt outright, exactly like the PostgreSQL sibling's own partial index does. Two structurally different database features — a conditional index in one case, NULL-exempt uniqueness on a computed column in the other — arrive at the identical real rule.
db_persist=True matters specifically for MySQL: GeneratedField with db_persist=True creates a real STORED column (its value genuinely written to disk on every insert/update), which MySQL 5.7+ supports and can build a real index on — a virtual, non-stored generated column has real limitations around indexing that make STORED the safer, portable choice here.
Recording (and Correcting) a Prediction: One Real Built-In Call
plpredict-fastapi1's own upsert route had to manually query for an existing row, branch, and either update or insert. Django's ORM already has this exact pattern built in:
Prediction.objects.update_or_create(**lookup, defaults={...}) looks for an existing row matching lookup, updates it with defaults if found, or creates a brand-new row with both lookup and defaults combined if not — the exact find-then-update-or-insert logic plpredict-fastapi1's own Chapter 5 wrote out explicitly as a query.first() check followed by an if/else. SQLAlchemy's Session API has no single built-in call for this; Django's QuerySet does, the same category of "gets this for free" payoff as Chapter 3's own SeasonTeamInline.
.values('id', 'source', ...) in list_predictions is a small, real convenience of its own — a queryset that returns plain dictionaries directly, ready for JsonResponse(list(...), safe=False), with no separate serializer layer needed for a response this simple.
Prediction row per guest, each with its own guest_name. Averaging multiple real scorelines into the single "guest" figure the prediction league table eventually scores is deliberately left for Chapter 6, exactly as in the FastAPI sibling course.
fixture.kickoff_time against the current time before accepting an upsert — the same honest, deliberately unaddressed gap the FastAPI sibling course also carries.
Recording Predictions From the Frontend
A compact, reusable form, sending the CSRF token established in Chapter 4 on every submission:
Where This Course Is Headed
Entering real results, defining how a correct score and a correct result actually get calculated, and resolving how multiple guest predictions become one comparable figure (Chapter 6); the real league table (Chapter 7); the prediction league table (Chapter 8); and promotion/relegation (Chapter 9).
Hands-On Exercises
Explain why unique_key evaluates to NULL for a guest prediction but a real string for every other source, and why that specific choice is what makes the UniqueConstraint on unique_key behave correctly for both cases.
📄 View solutionExplain why db_persist=True is specifically required for this GeneratedField to work correctly on MySQL, and what real database feature it corresponds to.
📄 View solutionRecord a user, an expert, an AI, and two separately-named guest predictions against a single real fixture using upsert_prediction, then submit a second user prediction for the same fixture and confirm it updates the existing row (created: false) rather than creating a duplicate.
📄 View solutionChapter 5 Quick Reference
- Prediction — one model, four sources (user/expert/guest/ai), keyed by fixture + source (+ guest_name for guests)
- unique_key — a real GeneratedField (Django 5.0+): NULL for guest rows, a deterministic fixture-source string otherwise
- Real MySQL workaround — NULL is never unique-constrained against another NULL, exploited here to get the identical guarantee PostgreSQL's own partial index provides
- db_persist=True — creates a real STORED generated column, required for MySQL 5.7+ to index it at all
- update_or_create() — Django's own real built-in upsert, replacing the FastAPI sibling's own hand-written find-then-branch logic
- Still deferred to Chapter 6 — how multiple guest scorelines become one comparable figure
- Next chapter: Entering results and calculating correct score vs. correct result