Capstone: Integrating the Predictor Into the Existing Astro Site
Premier League Predictor: Django & MySQL
Chapter 12 (Capstone) · Integrating the Predictor Into the Existing Astro Site
Eleven chapters have built a genuinely working predictor — a real normalized schema, admin tooling built largely for free, a fast fixture-entry UI, four scored prediction sources, two real league tables, promotion/relegation, a real navigation-based selector, and a real deployment plan. This closing chapter covers the actual point of building it: getting it in front of the user, mounted onto their real, existing Astro-based site, then reviews the whole course chapter by chapter.
Three Real Ways to Mount a Django App Onto an Astro Site
Astro's own live site is a static build served by a web server — it has no Python runtime of its own, and doesn't need one for its own pages. Integrating this project is a matter of getting both apps served correctly, side by side, behind the same web server:
| Option | What It Looks Like | Real Effort |
|---|---|---|
| Subdomain | predictor.example.com, a fully separate vhost |
Lowest — no code changes at all, just DNS + a new nginx server block |
| Path-mounted reverse proxy | example.com/predictor/, same domain as the Astro site |
Low — one nginx location block plus a real, two-part fix to this course's own hardcoded paths |
| JSON views + Astro frontend | Django returns data only; an Astro page/island renders it | Highest — every server-rendered page's own logic effectively rebuilt in Astro/React |
The Realistic Choice: Path-Mounted Reverse Proxy
A subdomain works but feels like more infrastructure than a personal project needs; a full JSON-view rewrite defeats much of the point of already having a working, server-rendered Django app. A path-mounted reverse proxy is the real middle ground:
proxy_pass http://127.0.0.1:8000/; — the trailing slash matters — strips /predictor/ before forwarding, so Django itself sees plain /gameweeks/7/fixtures/ requests exactly as every earlier chapter tested them, with no server-side URL pattern changes needed at all.
Two Real Kinds of Absolute Path — And Only One of Them FORCE_SCRIPT_NAME Actually Fixes
catalog-django1's own sibling capstone found real, server-generated {% url %} links breaking once mounted at a sub-path, fixed with Django's own FORCE_SCRIPT_NAME setting. This course hits a genuinely more layered version of that same problem, for a real, traceable reason: since Chapter 9, this course has deliberately hand-written every route as a raw path string rather than using Django's reverse()/named-URL system at all.
FORCE_SCRIPT_NAME genuinely does fix every link the Django admin itself generates — SeasonTeamInline's own save-and-continue redirects, its "view on site" links, its changelist navigation — because admin.site.urls builds every one of those internally via reverse(), and reverse() is exactly the machinery FORCE_SCRIPT_NAME teaches to prepend the real mount prefix. This payoff is real, and it costs nothing extra beyond the one setting above.
season_default_gameweek's own real route, from Chapter 10, reads return redirect(f'/gameweeks/{first_gameweek.id}/fixtures/') — a literal, hardcoded absolute string, not anything built via reverse(). FORCE_SCRIPT_NAME only ever rewrites paths Django's own URL-generation functions produce; it has no way to know that this particular string was ever meant to be treated as one of them. Even with FORCE_SCRIPT_NAME = '/predictor' correctly set, this exact line still redirects a browser to /gameweeks/7/fixtures/ — missing the prefix entirely — which nginx has no location block for, falling through to the Astro site's own catch-all instead of the predictor. Every fetch call in fixtures.js, predictions.js, rollover.js, and the selector partial has the exact same real problem, for the exact same reason: this course chose raw path strings deliberately, back in Chapter 9, "matching established convention" — and that choice has a real, concrete consequence right here.
The Real Fix: One Settings-Driven Prefix, Applied Everywhere by Hand
Every hand-written path across the whole app gets the same treatment — built from BASE_PATH explicitly, rather than left to FORCE_SCRIPT_NAME to rescue on its own:
The client-side JS fix is the FastAPI/PostgreSQL sibling course's own real technique, applied here for the same underlying reason: browser-side fetch() resolution has nothing to do with Django's own FORCE_SCRIPT_NAME at all — it's resolved entirely client-side, against the document's own base URL, which needs a real <base> tag to be correct:
WSGIRequest sets request.META['SCRIPT_NAME'] directly from FORCE_SCRIPT_NAME whenever it's configured — the exact setting already added above for the admin's own sake turns out to be precisely the right value for the <base> tag too, with no second setting needed. Locally, with BASE_PATH = '', FORCE_SCRIPT_NAME is None, request.META.SCRIPT_NAME is empty, and the {% if %} above simply omits the tag — fetch('gameweeks/...') then resolves correctly against http://localhost:8000/ with no base tag present at all, exactly the same "localhost needs nothing extra" property the FastAPI sibling's own version has.
STATIC_URL, from Chapter 11, needs the identical prefix for the same reason every other hand-built path does: STATIC_URL = f'{BASE_PATH}/static/'.
header('Location: ...') redirects breaking. Personal Catalogue (Django & PostgreSQL) found {% url %} links breaking, fixed entirely by FORCE_SCRIPT_NAME alone, since that course used reverse()-backed URLs consistently. Premier League Predictor (FastAPI & PostgreSQL) found absolute fetch() calls breaking, fixed with relative paths plus one <base> tag. This course needed both of those last two techniques at once, in the same app — FORCE_SCRIPT_NAME for the reverse()-backed admin, and the relative-path/base-tag technique for everything this course itself chose to hand-write — a direct, concrete consequence of Chapter 9's own deliberate "raw path strings, not named URLs" convention, four courses and one recurring underlying lesson later: the app never knows it's not at the web root unless something explicit tells it so, and exactly what that "something" looks like depends entirely on how each individual path was originally built.
Making It Feel Like Part of the Same Site
A reverse proxy solves reachability, not visual consistency. Chapter 10's own dark-theme CSS already gives the predictor a real look matching this course's own documentation — matching its accent colors to whatever the Astro site's own build actually uses is a small, one-time styling pass, and the natural place for a real "← Back to Main Site" link.
A Real Alternative for Deeper Integration: JSON Views That Already Exist
If, later, the current gameweek's own real league position or predictor standings need to appear directly on an Astro page rather than linking out, the reverse-proxy setup already built supports that too — league_table, prediction_table, and relegation_preview already return real JSON from Chapters 7-9, with no new backend code required at all for an Astro island to fetch() them directly. This isn't built out further here — it's flagged honestly as the real next step if the project's own scope ever grows toward a tighter Astro-embedded experience.
Capstone: What Each Chapter Actually Built
| Chapter | What It Contributed |
|---|---|
| 1 | The real project spec, a working Django + MySQL connection, and Chapter 1's own flagged empty-password comment, closed in Chapter 11 |
| 2 | The real Team/Season/SeasonTeam/Gameweek/Fixture schema, the dual related_name fix on Fixture, and the MySQL 8.0.16+ CheckConstraint gotcha |
| 3 | SeasonTeamInline and the Django admin as a real manual-entry tool, largely for free, plus the max_num/validate_max 20-team cap reused twice more later |
| 4 | The server-rendered click-to-pair fixture-entry UI, and Django's own real CSRF requirement — a genuinely new concern the FastAPI sibling never faces |
| 5 | The Prediction model, a real GeneratedField exploiting NULL's own uniqueness rules as MySQL's answer to a partial unique index, and update_or_create() |
| 6 | The real, confirmed point values (40/10), score_prediction(), and bulk_update() rescoring every prediction on a corrected result |
| 7 | The real league table via a hand-written UNION ALL and Team.objects.raw(), and a genuine Django ORM join-multiplication gotcha found and avoided |
| 8 | The prediction league table's two-pass guest-averaging CTE, connection.cursor()+dictfetchall(), and MySQL's more lenient NULL-in-UNION typing |
| 9 | Promotion/relegation — compute_league_table() reused, bulk_create()+transaction.atomic(), and a deliberate split reusing SeasonTeamInline for promoted teams |
| 10 | A real navigation-based selector and a context processor, replacing a JS CustomEvent, plus a server-side fix to Chapter 4's own flagged usedTeamIds gap |
| 11 | Deployment — gunicorn, collectstatic+WhiteNoise, DEBUG/ALLOWED_HOSTS/admin hardening, and the real CONN_MAX_AGE connection-math calculation |
| 12 | Mounting the finished predictor behind the same domain as the real, existing Astro site |
What's Still Genuinely Open
project_issues.md's own P3 entry named several real questions this course deliberately didn't resolve — not oversights, but genuine product decisions left for whenever they're actually needed: whether past seasons get backfilled retroactively, whether a real fixture ever needs to move between gameweeks after being entered, and whether this stays a genuinely single-user tool or eventually needs real multi-user support (a question this course's own lack of any login requirement already anticipates). None of these blocked building a genuinely working predictor — they're simply the honest list of what comes next, if it ever needs to.
Hands-On Exercises
Write the fixed version of season_default_gameweek using settings.BASE_PATH, then show the exact Location header value the unfixed version and the fixed version would each send to the browser when mounted at /predictor/ — confirming FORCE_SCRIPT_NAME alone changes neither one.
📄 View solutionExplain why the same FORCE_SCRIPT_NAME setting that fixes every link the Django admin generates has no effect at all on season_default_gameweek's own redirect() call, tracing the distinction back to reverse()-built paths versus a raw hardcoded string.
📄 View solutionMount the app behind a real nginx location /predictor/ block with FORCE_SCRIPT_NAME set but BASE_PATH not yet applied to any hand-written path, confirm a SeasonTeamInline save-and-continue redirect inside the admin works correctly, then confirm season_default_gameweek genuinely 404s at the wrong URL until the BASE_PATH fix from this chapter is applied to it too.
📄 View solutionChapter 12 Quick Reference
- Path-mounted reverse proxy — one nginx
location /predictor/block usingproxy_passto Gunicorn, stripping the prefix - FORCE_SCRIPT_NAME — fixes the Django admin's own links automatically, since they're built via reverse()
- The real gap — every route this course hand-wrote since Chapter 9 uses a raw absolute path string, which FORCE_SCRIPT_NAME cannot touch at all
- The fix — a real BASE_PATH setting, applied explicitly to every redirect() call, every fetch() call, and STATIC_URL, plus one <base> tag templated from request.META.SCRIPT_NAME
- Four sibling courses, a genuine hybrid here — this course needs both FORCE_SCRIPT_NAME (for the admin) and the relative-path/base-tag technique (for everything hand-written), unlike either sibling alone
- JSON views — already exist, for free, at league_table/prediction_table/relegation_preview; a real, honest next step for tighter Astro-embedded integration
- Still genuinely open — historical backfill, fixture rescheduling workflow, and single-vs-multi-user support, per project_issues.md P3