Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
536832a524 | ||
|
|
87642b7b06 | ||
|
|
929748d014 | ||
|
|
4e8df006d7 |
@@ -51,11 +51,14 @@ jobs:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
run: pip install -r requirements.txt pytest "httpx<0.28"
|
||||
|
||||
- name: Import smoke test
|
||||
run: python -c "from backend.app import app; print('backend imports OK')"
|
||||
|
||||
- name: Backend unit tests
|
||||
run: python -m pytest backend/tests -q
|
||||
|
||||
build-backend:
|
||||
name: Build Backend (no push)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
venv
|
||||
backend/__pycache__
|
||||
__pycache__/
|
||||
|
||||
+13
-4
@@ -33,7 +33,7 @@ from .data_loader import (
|
||||
)
|
||||
from .data_loader import get_data_info as get_db_info
|
||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||
from .utils import clean_for_json
|
||||
from .utils import clean_for_json, convert_to_native
|
||||
|
||||
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
||||
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
|
||||
@@ -582,8 +582,13 @@ async def get_school_details(request: Request, urn: int):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"school_info": {
|
||||
# Schools with no performance rows (post-16 institutions, PRUs, new
|
||||
# schools) carry NaN in every LEFT-JOINed numeric column; NaN reaching
|
||||
# JSONResponse raises ValueError, so school_info needs the same
|
||||
# conversion yearly_data gets from clean_for_json.
|
||||
school_info = {
|
||||
k: convert_to_native(v)
|
||||
for k, v in {
|
||||
"urn": urn,
|
||||
"school_name": latest.get("school_name", ""),
|
||||
"local_authority": latest.get("local_authority", ""),
|
||||
@@ -601,7 +606,11 @@ async def get_school_details(request: Request, urn: int):
|
||||
"total_pupils": latest.get("gias_total_pupils"),
|
||||
"trust_name": latest.get("trust_name"),
|
||||
"gender": latest.get("gender"),
|
||||
},
|
||||
}.items()
|
||||
}
|
||||
|
||||
return {
|
||||
"school_info": school_info,
|
||||
"yearly_data": clean_for_json(school_data),
|
||||
# Supplementary data (null if not yet populated by Kestra)
|
||||
"ofsted": supplementary.get("ofsted"),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Regression tests for GET /api/schools/{urn}.
|
||||
|
||||
Schools with no performance rows (special post-16 institutions, sixth-form
|
||||
centres, PRUs, brand-new schools) come back from the marts LEFT JOIN with
|
||||
NaN in every numeric column. The endpoint must still serialize them — a NaN
|
||||
that reaches Starlette's JSONResponse raises ValueError (allow_nan=False)
|
||||
and the route 500s, which the frontend then renders as a 404.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _no_results_school_df() -> pd.DataFrame:
|
||||
"""One school row as produced by the marts query for a school with no
|
||||
performance data: GIAS/location fields partly populated, every
|
||||
results-linked column NaN (including year)."""
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"urn": 150275,
|
||||
"school_name": "West London Performing Arts Academy",
|
||||
"phase": "Secondary",
|
||||
"school_type": "Special post 16 institution",
|
||||
"trust_name": None,
|
||||
"religious_denomination": "Does not apply",
|
||||
"gender": None,
|
||||
"age_range": "16-25",
|
||||
"admissions_policy": None,
|
||||
"capacity": np.nan,
|
||||
"gias_total_pupils": np.nan,
|
||||
"headteacher_name": None,
|
||||
"website": None,
|
||||
"ofsted_grade": np.nan,
|
||||
"local_authority": "Ealing",
|
||||
"address": "268 Northfield Avenue, London, W5 4UB",
|
||||
"postcode": "W5 4UB",
|
||||
"latitude": 51.4986,
|
||||
"longitude": -0.3148,
|
||||
"year": np.nan,
|
||||
"total_pupils": np.nan,
|
||||
"eligible_pupils": np.nan,
|
||||
"rwm_expected_pct": np.nan,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _no_results_school_df)
|
||||
monkeypatch.setattr(
|
||||
app_module, "get_supplementary_data", lambda db, urn: {}
|
||||
)
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_school_without_performance_rows_returns_200(client):
|
||||
resp = client.get("/api/schools/150275")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def test_nan_gias_fields_serialize_as_null(client):
|
||||
info = client.get("/api/schools/150275").json()["school_info"]
|
||||
assert info["capacity"] is None
|
||||
assert info["total_pupils"] is None
|
||||
assert info["school_name"] == "West London Performing Arts Academy"
|
||||
@@ -25,6 +25,16 @@ test('home page loads with hero search', async ({ page }) => {
|
||||
await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('home hero offers a "use my location" shortcut beside the search box', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// The geolocation shortcut lives inside the hero search card, right under the
|
||||
// search input — not in a separate strip further down the page.
|
||||
const searchInput = page.getByPlaceholder('School name or postcode').first();
|
||||
await expect(searchInput).toBeVisible();
|
||||
const nearMe = page.getByRole('button', { name: /use my location/i });
|
||||
await expect(nearMe).toBeVisible();
|
||||
});
|
||||
|
||||
test('searching by name returns school results', async ({ page }) => {
|
||||
await searchByName(page, 'primary');
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
@@ -50,6 +60,34 @@ test('school detail page renders name and performance data', async ({ page }) =>
|
||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('school with no performance data still gets a working detail page', async ({ page }) => {
|
||||
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
|
||||
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
||||
// serialization — which the frontend rendered as a 404 on every such SEO
|
||||
// landing page. Find one via the search API (year === null marks "no
|
||||
// performance rows") and assert its page renders.
|
||||
const candidates: number[] = [];
|
||||
for (const q of ['post 16', 'specialist college', 'sixth form']) {
|
||||
const resp = await page.request.get(
|
||||
`/api/schools?search=${encodeURIComponent(q)}&per_page=20`
|
||||
);
|
||||
if (!resp.ok()) continue;
|
||||
const body = await resp.json();
|
||||
for (const s of body.schools ?? []) {
|
||||
if (s.year === null && s.urn) candidates.push(s.urn);
|
||||
}
|
||||
if (candidates.length) break;
|
||||
}
|
||||
test.skip(candidates.length === 0, 'no results-less school in this dataset');
|
||||
|
||||
const detail = await page.request.get(`/api/schools/${candidates[0]}`);
|
||||
expect(detail.status(), 'detail API must not 500 for a results-less school').toBe(200);
|
||||
|
||||
await page.goto(`/school/${candidates[0]}`);
|
||||
await page.waitForURL(/\/school\/\d+-/); // redirected to canonical slug
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => {
|
||||
// iOS Safari has no Element.requestFullscreen; the map must fall back to a
|
||||
// CSS overlay. Simulate that by removing the API before any page script runs.
|
||||
|
||||
@@ -36,6 +36,91 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
margin: 0.875rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.searchHint strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.searchHint {
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.375rem;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
background: #235f5f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeSpinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.geoError {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.nearMeBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.searchSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,21 @@ interface FilterBarProps {
|
||||
filters: Filters;
|
||||
isHero?: boolean;
|
||||
resultFilters?: ResultFilters;
|
||||
// Geolocation "use my location" affordance, shown beside the hero search box.
|
||||
// The state and handler live in HomeView (which owns the geolocation flow).
|
||||
onNearMe?: () => void;
|
||||
geoState?: "idle" | "requesting" | "error";
|
||||
geoError?: string | null;
|
||||
}
|
||||
|
||||
export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
||||
export function FilterBar({
|
||||
filters,
|
||||
isHero,
|
||||
resultFilters,
|
||||
onNearMe,
|
||||
geoState = "idle",
|
||||
geoError,
|
||||
}: FilterBarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -182,6 +194,52 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{isHero && (
|
||||
<>
|
||||
<p className={styles.searchHint}>
|
||||
Search by <strong>school name</strong> — or use your{" "}
|
||||
<strong>postcode</strong> for the nearest schools.
|
||||
</p>
|
||||
{onNearMe && (
|
||||
<div className={styles.nearMeRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.nearMeBtn}
|
||||
onClick={onNearMe}
|
||||
disabled={geoState === "requesting"}
|
||||
>
|
||||
{geoState === "requesting" ? (
|
||||
<>
|
||||
<span className={styles.nearMeSpinner} aria-hidden="true" />
|
||||
Locating you…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z" />
|
||||
<circle cx="12" cy="9" r="2.5" />
|
||||
</svg>
|
||||
Use my location
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{geoError && (
|
||||
<p className={styles.geoError} role="alert">
|
||||
{geoError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{!isHero && (
|
||||
|
||||
@@ -506,68 +506,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.discoverySection {
|
||||
padding: 0.5rem 0 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.375rem;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
background: #235f5f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeBtnSpinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.geoError {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quickSearches {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -284,37 +284,11 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
filters={filters}
|
||||
isHero={!isSearchActive}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
|
||||
{/* Discovery section shown on landing page before any search */}
|
||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||
<div className={styles.discoverySection}>
|
||||
<div className={styles.nearMeRow}>
|
||||
<button
|
||||
className={styles.nearMeBtn}
|
||||
onClick={handleNearMe}
|
||||
disabled={geoState === 'requesting'}
|
||||
>
|
||||
{geoState === 'requesting' ? (
|
||||
<>
|
||||
<span className={styles.nearMeBtnSpinner} aria-hidden="true" />
|
||||
Locating you…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z"/>
|
||||
<circle cx="12" cy="9" r="2.5"/>
|
||||
</svg>
|
||||
Schools near me
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{geoError && <p className={styles.geoError} role="alert">{geoError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admissions countdown strip — only on landing page */}
|
||||
{!isSearchActive && (
|
||||
<section className={styles.admissionsStrip}>
|
||||
|
||||
Reference in New Issue
Block a user