Compare commits
38 Commits
0aafdfa382
...
add-contac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75677f4252 | ||
|
|
9b6c37cda3 | ||
|
|
f2eec08bd4 | ||
|
|
f7b9a4d28e | ||
|
|
c23e12fc12 | ||
|
|
a8fe4477f1 | ||
|
|
1a9341eaf4 | ||
|
|
708fbe83a0 | ||
|
|
8e4802df93 | ||
|
|
a18ec04227 | ||
|
|
9cd36a0b15 | ||
|
|
1f6b2dd773 | ||
|
|
6597ee40fb | ||
|
|
bb58d607c2 | ||
|
|
e1383b3432 | ||
|
|
3c1e7b4b27 | ||
|
|
597a841d4d | ||
|
|
ab45f66431 | ||
|
|
c63e0e2682 | ||
|
|
79cf16d6b3 | ||
|
|
e3fc031ecf | ||
|
|
058a741b10 | ||
|
|
ea3f65249e | ||
|
|
b0e2a42acc | ||
|
|
1e6019eac3 | ||
|
|
3f118ef826 | ||
|
|
8458d638ec | ||
|
|
51836852e4 | ||
|
|
116be294a3 | ||
|
|
4b91eb403a | ||
|
|
6623418dbe | ||
|
|
3f8e1911aa | ||
|
|
b7943e1042 | ||
|
|
34f40c0c1c | ||
|
|
1d19c88e49 | ||
|
|
40348cb1bd | ||
|
|
73971a43f0 | ||
|
|
39d0de751b |
@@ -50,3 +50,8 @@ jobs:
|
|||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Trigger Portainer stack update
|
||||||
|
if: gitea.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
curl -X POST -k "https://10.0.1.224:9443/api/stacks/webhooks/863fc57c-bf24-4c63-9001-bdf9912fba73"
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
venv
|
venv
|
||||||
|
backend/__pycache__
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -179,6 +179,31 @@ Data is sourced from the UK Government's [Compare School Performance](https://ww
|
|||||||
|
|
||||||
**Important**: When using real data, please comply with the [terms of use](https://www.compare-school-performance.service.gov.uk/download-data) and data protection regulations.
|
**Important**: When using real data, please comply with the [terms of use](https://www.compare-school-performance.service.gov.uk/download-data) and data protection regulations.
|
||||||
|
|
||||||
|
## Scheduled Jobs
|
||||||
|
|
||||||
|
### Geocoding Schools (Cron Job)
|
||||||
|
|
||||||
|
School postcodes are geocoded by a scheduled job, not on-demand. This improves performance and reduces API calls.
|
||||||
|
|
||||||
|
**Setup the cron job** (runs weekly on Sunday at 2am):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit crontab
|
||||||
|
crontab -e
|
||||||
|
|
||||||
|
# Add this line (adjust paths as needed):
|
||||||
|
0 2 * * 0 cd /path/to/school_compare && /path/to/venv/bin/python scripts/geocode_schools.py >> /var/log/geocode_schools.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual run:**
|
||||||
|
```bash
|
||||||
|
# Geocode only schools missing coordinates
|
||||||
|
python scripts/geocode_schools.py
|
||||||
|
|
||||||
|
# Force re-geocode all schools
|
||||||
|
python scripts/geocode_schools.py --force
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - feel free to use this project for educational purposes.
|
MIT License - feel free to use this project for educational purposes.
|
||||||
|
|||||||
107
backend/app.py
107
backend/app.py
@@ -8,6 +8,7 @@ import re
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
|
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -21,10 +22,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .data_loader import (
|
from .data_loader import (
|
||||||
clear_cache,
|
clear_cache,
|
||||||
geocode_postcodes_bulk,
|
|
||||||
geocode_single_postcode,
|
|
||||||
haversine_distance,
|
|
||||||
load_school_data,
|
load_school_data,
|
||||||
|
geocode_single_postcode,
|
||||||
)
|
)
|
||||||
from .data_loader import get_data_info as get_db_info
|
from .data_loader import get_data_info as get_db_info
|
||||||
from .database import init_db
|
from .database import init_db
|
||||||
@@ -66,14 +65,14 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
# Content Security Policy
|
# Content Security Policy
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com https://www.googletagmanager.com; "
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; "
|
||||||
"font-src 'self' https://fonts.gstatic.com; "
|
"font-src 'self' https://fonts.gstatic.com; "
|
||||||
"img-src 'self' data:; "
|
"img-src 'self' data: https://*.tile.openstreetmap.org https://unpkg.com https://www.google-analytics.com; "
|
||||||
"connect-src 'self' https://cdn.jsdelivr.net; "
|
"connect-src 'self' https://cdn.jsdelivr.net https://*.tile.openstreetmap.org https://unpkg.com https://www.google-analytics.com https://analytics.google.com https://*.google-analytics.com; "
|
||||||
"frame-ancestors 'none'; "
|
"frame-ancestors 'none'; "
|
||||||
"base-uri 'self'; "
|
"base-uri 'self'; "
|
||||||
"form-action 'self';"
|
"form-action 'self' https://formsubmit.co;"
|
||||||
)
|
)
|
||||||
|
|
||||||
# HSTS (only enable if using HTTPS in production)
|
# HSTS (only enable if using HTTPS in production)
|
||||||
@@ -200,6 +199,14 @@ async def serve_rankings():
|
|||||||
return FileResponse(settings.frontend_dir / "index.html")
|
return FileResponse(settings.frontend_dir / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/config")
|
||||||
|
async def get_config():
|
||||||
|
"""Return public configuration for the frontend."""
|
||||||
|
return {
|
||||||
|
"ga_measurement_id": settings.ga_measurement_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/schools")
|
@app.get("/api/schools")
|
||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_schools(
|
async def get_schools(
|
||||||
@@ -239,11 +246,23 @@ async def get_schools(
|
|||||||
latest_year = df.groupby("urn")["year"].max().reset_index()
|
latest_year = df.groupby("urn")["year"].max().reset_index()
|
||||||
df_latest = df.merge(latest_year, on=["urn", "year"])
|
df_latest = df.merge(latest_year, on=["urn", "year"])
|
||||||
|
|
||||||
|
# Calculate trend by comparing to previous year
|
||||||
|
# Get second-latest year for each school
|
||||||
|
df_sorted = df.sort_values(["urn", "year"], ascending=[True, False])
|
||||||
|
df_prev = df_sorted.groupby("urn").nth(1).reset_index()
|
||||||
|
if not df_prev.empty and "rwm_expected_pct" in df_prev.columns:
|
||||||
|
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
||||||
|
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
||||||
|
)
|
||||||
|
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
|
||||||
|
|
||||||
# Include key result metrics for display on cards
|
# Include key result metrics for display on cards
|
||||||
location_cols = ["latitude", "longitude"]
|
location_cols = ["latitude", "longitude"]
|
||||||
result_cols = [
|
result_cols = [
|
||||||
"year",
|
"year",
|
||||||
"rwm_expected_pct",
|
"rwm_expected_pct",
|
||||||
|
"rwm_high_pct",
|
||||||
|
"prev_rwm_expected_pct",
|
||||||
"reading_expected_pct",
|
"reading_expected_pct",
|
||||||
"writing_expected_pct",
|
"writing_expected_pct",
|
||||||
"maths_expected_pct",
|
"maths_expected_pct",
|
||||||
@@ -256,7 +275,7 @@ async def get_schools(
|
|||||||
]
|
]
|
||||||
schools_df = df_latest[available_cols].drop_duplicates(subset=["urn"])
|
schools_df = df_latest[available_cols].drop_duplicates(subset=["urn"])
|
||||||
|
|
||||||
# Location-based search
|
# Location-based search (uses pre-geocoded data from database)
|
||||||
search_coords = None
|
search_coords = None
|
||||||
if postcode:
|
if postcode:
|
||||||
coords = geocode_single_postcode(postcode)
|
coords = geocode_single_postcode(postcode)
|
||||||
@@ -264,35 +283,36 @@ async def get_schools(
|
|||||||
search_coords = coords
|
search_coords = coords
|
||||||
schools_df = schools_df.copy()
|
schools_df = schools_df.copy()
|
||||||
|
|
||||||
# Geocode school postcodes on-demand if not already cached
|
# Filter by distance using pre-geocoded lat/long from database
|
||||||
if "postcode" in schools_df.columns:
|
# Use vectorized haversine calculation for better performance
|
||||||
unique_postcodes = schools_df["postcode"].dropna().unique().tolist()
|
lat1, lon1 = search_coords
|
||||||
geocoded = geocode_postcodes_bulk(unique_postcodes)
|
|
||||||
|
|
||||||
# Add lat/long from geocoded data
|
# Handle potential duplicate columns by taking first occurrence
|
||||||
schools_df["latitude"] = schools_df["postcode"].apply(
|
lat_col = schools_df.loc[:, "latitude"]
|
||||||
lambda pc: geocoded.get(str(pc).strip().upper(), (None, None))[0]
|
lon_col = schools_df.loc[:, "longitude"]
|
||||||
if pd.notna(pc)
|
if isinstance(lat_col, pd.DataFrame):
|
||||||
else None
|
lat_col = lat_col.iloc[:, 0]
|
||||||
)
|
if isinstance(lon_col, pd.DataFrame):
|
||||||
schools_df["longitude"] = schools_df["postcode"].apply(
|
lon_col = lon_col.iloc[:, 0]
|
||||||
lambda pc: geocoded.get(str(pc).strip().upper(), (None, None))[1]
|
|
||||||
if pd.notna(pc)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter by distance
|
lat2 = lat_col.values
|
||||||
def calc_distance(row):
|
lon2 = lon_col.values
|
||||||
if pd.isna(row.get("latitude")) or pd.isna(row.get("longitude")):
|
|
||||||
return float("inf")
|
|
||||||
return haversine_distance(
|
|
||||||
search_coords[0],
|
|
||||||
search_coords[1],
|
|
||||||
row["latitude"],
|
|
||||||
row["longitude"],
|
|
||||||
)
|
|
||||||
|
|
||||||
schools_df["distance"] = schools_df.apply(calc_distance, axis=1)
|
# Vectorized haversine formula
|
||||||
|
R = 3959 # Earth's radius in miles
|
||||||
|
lat1_rad = np.radians(lat1)
|
||||||
|
lat2_rad = np.radians(lat2)
|
||||||
|
dlat = np.radians(lat2 - lat1)
|
||||||
|
dlon = np.radians(lon2 - lon1)
|
||||||
|
|
||||||
|
a = np.sin(dlat / 2) ** 2 + np.cos(lat1_rad) * np.cos(lat2_rad) * np.sin(dlon / 2) ** 2
|
||||||
|
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
|
||||||
|
distances = R * c
|
||||||
|
|
||||||
|
# Handle missing coordinates
|
||||||
|
has_coords = ~(pd.isna(lat_col) | pd.isna(lon_col))
|
||||||
|
distances = np.where(has_coords.values, distances, float("inf"))
|
||||||
|
schools_df["distance"] = distances
|
||||||
schools_df = schools_df[schools_df["distance"] <= radius]
|
schools_df = schools_df[schools_df["distance"] <= radius]
|
||||||
schools_df = schools_df.sort_values("distance")
|
schools_df = schools_df.sort_values("distance")
|
||||||
|
|
||||||
@@ -324,13 +344,8 @@ async def get_schools(
|
|||||||
end_idx = start_idx + page_size
|
end_idx = start_idx + page_size
|
||||||
schools_df = schools_df.iloc[start_idx:end_idx]
|
schools_df = schools_df.iloc[start_idx:end_idx]
|
||||||
|
|
||||||
# Remove internal columns before sending
|
|
||||||
output_cols = [c for c in schools_df.columns if c not in ["latitude", "longitude"]]
|
|
||||||
if "distance" in schools_df.columns:
|
|
||||||
output_cols.append("distance")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"schools": clean_for_json(schools_df[output_cols]),
|
"schools": clean_for_json(schools_df),
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
@@ -372,6 +387,10 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"local_authority": latest.get("local_authority", ""),
|
"local_authority": latest.get("local_authority", ""),
|
||||||
"school_type": latest.get("school_type", ""),
|
"school_type": latest.get("school_type", ""),
|
||||||
"address": latest.get("address", ""),
|
"address": latest.get("address", ""),
|
||||||
|
"religious_denomination": latest.get("religious_denomination", ""),
|
||||||
|
"age_range": latest.get("age_range", ""),
|
||||||
|
"latitude": latest.get("latitude"),
|
||||||
|
"longitude": latest.get("longitude"),
|
||||||
"phase": "Primary",
|
"phase": "Primary",
|
||||||
},
|
},
|
||||||
"yearly_data": clean_for_json(school_data),
|
"yearly_data": clean_for_json(school_data),
|
||||||
@@ -589,6 +608,12 @@ async def reload_data(
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/favicon.svg")
|
||||||
|
async def favicon():
|
||||||
|
"""Serve favicon."""
|
||||||
|
return FileResponse(settings.frontend_dir / "favicon.svg", media_type="image/svg+xml")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/robots.txt")
|
@app.get("/robots.txt")
|
||||||
async def robots_txt():
|
async def robots_txt():
|
||||||
"""Serve robots.txt for search engine crawlers."""
|
"""Serve robots.txt for search engine crawlers."""
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
|
|||||||
rate_limit_burst: int = 10 # Allow burst of requests
|
rate_limit_burst: int = 10 # Allow burst of requests
|
||||||
max_request_size: int = 1024 * 1024 # 1MB max request size
|
max_request_size: int = 1024 * 1024 # 1MB max request size
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = "utf-8"
|
env_file_encoding = "utf-8"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Data loading module that queries from PostgreSQL database.
|
Data loading module that queries from PostgreSQL database.
|
||||||
Provides efficient queries with caching and lazy loading.
|
Provides efficient queries with caching and lazy loading.
|
||||||
|
|
||||||
|
Note: School geocoding is handled by a separate cron job (scripts/geocode_schools.py).
|
||||||
|
Only user search postcodes are geocoded on-demand via geocode_single_postcode().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -14,57 +17,37 @@ from sqlalchemy.orm import joinedload, Session
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import SessionLocal, get_db_session
|
from .database import SessionLocal, get_db_session
|
||||||
from .models import School, SchoolResult
|
from .models import School, SchoolResult
|
||||||
|
from .schemas import SCHOOL_TYPE_MAP
|
||||||
|
|
||||||
# Cache for postcode geocoding
|
# Cache for user search postcode geocoding (not for school data)
|
||||||
_postcode_cache: Dict[str, Tuple[float, float]] = {}
|
_postcode_cache: Dict[str, Tuple[float, float]] = {}
|
||||||
|
|
||||||
|
|
||||||
def geocode_postcodes_bulk(postcodes: list) -> Dict[str, Tuple[float, float]]:
|
def normalize_school_type(school_type: Optional[str]) -> Optional[str]:
|
||||||
"""
|
"""Convert cryptic school type codes to user-friendly names."""
|
||||||
Geocode postcodes in bulk using postcodes.io API.
|
if not school_type:
|
||||||
Returns dict of postcode -> (latitude, longitude).
|
return None
|
||||||
"""
|
# Check if it's a code that needs mapping
|
||||||
results = {}
|
code = school_type.strip().upper()
|
||||||
|
if code in SCHOOL_TYPE_MAP:
|
||||||
|
return SCHOOL_TYPE_MAP[code]
|
||||||
|
# Return original if already a friendly name or unknown code
|
||||||
|
return school_type
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
uncached = []
|
|
||||||
for pc in postcodes:
|
|
||||||
if pc and isinstance(pc, str):
|
|
||||||
pc_upper = pc.strip().upper()
|
|
||||||
if pc_upper in _postcode_cache:
|
|
||||||
results[pc_upper] = _postcode_cache[pc_upper]
|
|
||||||
elif len(pc_upper) >= 5:
|
|
||||||
uncached.append(pc_upper)
|
|
||||||
|
|
||||||
if not uncached:
|
def get_school_type_codes_for_filter(school_type: str) -> List[str]:
|
||||||
return results
|
"""Get all database codes that map to a given friendly name."""
|
||||||
|
if not school_type:
|
||||||
uncached = list(set(uncached))
|
return []
|
||||||
|
school_type_lower = school_type.lower()
|
||||||
# postcodes.io allows max 100 postcodes per request
|
# Collect all codes that map to this friendly name
|
||||||
batch_size = 100
|
codes = []
|
||||||
for i in range(0, len(uncached), batch_size):
|
for code, friendly_name in SCHOOL_TYPE_MAP.items():
|
||||||
batch = uncached[i:i + batch_size]
|
if friendly_name.lower() == school_type_lower:
|
||||||
try:
|
codes.append(code.lower())
|
||||||
response = requests.post(
|
# Also include the school_type itself (case-insensitive) in case it's stored as-is
|
||||||
'https://api.postcodes.io/postcodes',
|
codes.append(school_type_lower)
|
||||||
json={'postcodes': batch},
|
return codes
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
for item in data.get('result', []):
|
|
||||||
if item and item.get('result'):
|
|
||||||
pc = item['query'].upper()
|
|
||||||
lat = item['result'].get('latitude')
|
|
||||||
lon = item['result'].get('longitude')
|
|
||||||
if lat and lon:
|
|
||||||
results[pc] = (lat, lon)
|
|
||||||
_postcode_cache[pc] = (lat, lon)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Warning: Geocoding batch failed: {e}")
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def geocode_single_postcode(postcode: str) -> Optional[Tuple[float, float]]:
|
def geocode_single_postcode(postcode: str) -> Optional[Tuple[float, float]]:
|
||||||
@@ -160,7 +143,7 @@ def get_available_local_authorities(db: Session = None) -> List[str]:
|
|||||||
|
|
||||||
|
|
||||||
def get_available_school_types(db: Session = None) -> List[str]:
|
def get_available_school_types(db: Session = None) -> List[str]:
|
||||||
"""Get list of available school types."""
|
"""Get list of available school types (normalized to user-friendly names)."""
|
||||||
close_db = db is None
|
close_db = db is None
|
||||||
if db is None:
|
if db is None:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
@@ -169,9 +152,15 @@ def get_available_school_types(db: Session = None) -> List[str]:
|
|||||||
result = db.query(School.school_type)\
|
result = db.query(School.school_type)\
|
||||||
.filter(School.school_type.isnot(None))\
|
.filter(School.school_type.isnot(None))\
|
||||||
.distinct()\
|
.distinct()\
|
||||||
.order_by(School.school_type)\
|
|
||||||
.all()
|
.all()
|
||||||
return [r[0] for r in result if r[0]]
|
# Normalize codes to friendly names and deduplicate
|
||||||
|
normalized = set()
|
||||||
|
for r in result:
|
||||||
|
if r[0]:
|
||||||
|
friendly_name = normalize_school_type(r[0])
|
||||||
|
if friendly_name:
|
||||||
|
normalized.add(friendly_name)
|
||||||
|
return sorted(normalized)
|
||||||
finally:
|
finally:
|
||||||
if close_db:
|
if close_db:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -219,7 +208,10 @@ def get_schools(
|
|||||||
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
|
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
|
||||||
|
|
||||||
if school_type:
|
if school_type:
|
||||||
query = query.filter(func.lower(School.school_type) == school_type.lower())
|
# Filter by all codes that map to this friendly name
|
||||||
|
type_codes = get_school_type_codes_for_filter(school_type)
|
||||||
|
if type_codes:
|
||||||
|
query = query.filter(func.lower(School.school_type).in_(type_codes))
|
||||||
|
|
||||||
# Get total count
|
# Get total count
|
||||||
total = query.count()
|
total = query.count()
|
||||||
@@ -267,7 +259,10 @@ def get_schools_near_location(
|
|||||||
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
|
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
|
||||||
|
|
||||||
if school_type:
|
if school_type:
|
||||||
query = query.filter(func.lower(School.school_type) == school_type.lower())
|
# Filter by all codes that map to this friendly name
|
||||||
|
type_codes = get_school_type_codes_for_filter(school_type)
|
||||||
|
if type_codes:
|
||||||
|
query = query.filter(func.lower(School.school_type).in_(type_codes))
|
||||||
|
|
||||||
# Get all matching schools and calculate distances
|
# Get all matching schools and calculate distances
|
||||||
all_schools = query.all()
|
all_schools = query.all()
|
||||||
@@ -380,7 +375,7 @@ def school_to_dict(school: School, include_results: bool = False) -> dict:
|
|||||||
"urn": school.urn,
|
"urn": school.urn,
|
||||||
"school_name": school.school_name,
|
"school_name": school.school_name,
|
||||||
"local_authority": school.local_authority,
|
"local_authority": school.local_authority,
|
||||||
"school_type": school.school_type,
|
"school_type": normalize_school_type(school.school_type),
|
||||||
"address": school.address,
|
"address": school.address,
|
||||||
"town": school.town,
|
"town": school.town,
|
||||||
"postcode": school.postcode,
|
"postcode": school.postcode,
|
||||||
@@ -467,7 +462,7 @@ def load_school_data_as_dataframe(db: Session = None) -> pd.DataFrame:
|
|||||||
"urn": school.urn,
|
"urn": school.urn,
|
||||||
"school_name": school.school_name,
|
"school_name": school.school_name,
|
||||||
"local_authority": school.local_authority,
|
"local_authority": school.local_authority,
|
||||||
"school_type": school.school_type,
|
"school_type": normalize_school_type(school.school_type),
|
||||||
"address": school.address,
|
"address": school.address,
|
||||||
"town": school.town,
|
"town": school.town,
|
||||||
"postcode": school.postcode,
|
"postcode": school.postcode,
|
||||||
|
|||||||
@@ -101,16 +101,24 @@ NUMERIC_COLUMNS = [
|
|||||||
"maths_avg_3yr",
|
"maths_avg_3yr",
|
||||||
]
|
]
|
||||||
|
|
||||||
# School type code to name mapping
|
# School type code to user-friendly name mapping
|
||||||
SCHOOL_TYPE_MAP = {
|
SCHOOL_TYPE_MAP = {
|
||||||
|
# Academies
|
||||||
"AC": "Academy",
|
"AC": "Academy",
|
||||||
"ACC": "Academy Converter",
|
"ACC": "Academy",
|
||||||
"ACS": "Academy Sponsor Led",
|
"ACCS": "Academy",
|
||||||
"CY": "Community School",
|
"ACS": "Academy (Sponsor Led)",
|
||||||
|
# Community Schools
|
||||||
|
"CY": "Community",
|
||||||
|
"CYS": "Community",
|
||||||
|
# Voluntary Schools
|
||||||
"VA": "Voluntary Aided",
|
"VA": "Voluntary Aided",
|
||||||
"VC": "Voluntary Controlled",
|
"VC": "Voluntary Controlled",
|
||||||
|
# Foundation Schools
|
||||||
"FD": "Foundation",
|
"FD": "Foundation",
|
||||||
"F": "Foundation",
|
"F": "Foundation",
|
||||||
|
"FDS": "Foundation",
|
||||||
|
# Free Schools
|
||||||
"FS": "Free School",
|
"FS": "Free School",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +413,10 @@ SCHOOL_COLUMNS = [
|
|||||||
"address",
|
"address",
|
||||||
"town",
|
"town",
|
||||||
"postcode",
|
"postcode",
|
||||||
|
"religious_denomination",
|
||||||
|
"age_range",
|
||||||
|
"latitude",
|
||||||
|
"longitude",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Local Authority code to name mapping (for fallback when LANAME column missing)
|
# Local Authority code to name mapping (for fallback when LANAME column missing)
|
||||||
|
|||||||
857
frontend/app.js
857
frontend/app.js
File diff suppressed because it is too large
Load Diff
6
frontend/favicon.svg
Normal file
6
frontend/favicon.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="40" height="40" rx="8" fill="#1a1612"/>
|
||||||
|
<circle cx="20" cy="20" r="14" stroke="#e07256" stroke-width="2"/>
|
||||||
|
<path d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26" stroke="#e07256" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="20" cy="20" r="3" fill="#e07256"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 374 B |
@@ -11,6 +11,9 @@
|
|||||||
<meta name="author" content="SchoolCompare">
|
<meta name="author" content="SchoolCompare">
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow">
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
|
||||||
<!-- Canonical -->
|
<!-- Canonical -->
|
||||||
<link rel="canonical" href="https://schoolcompare.co.uk/">
|
<link rel="canonical" href="https://schoolcompare.co.uk/">
|
||||||
|
|
||||||
@@ -54,7 +57,12 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<!-- Leaflet Map Library -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
<!-- Cookie Consent Banner -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/silktide/consent-manager@main/silktide-consent-manager.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="noise-overlay"></div>
|
<div class="noise-overlay"></div>
|
||||||
@@ -75,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/" class="nav-link active" data-view="dashboard">Dashboard</a>
|
<a href="/" class="nav-link active" data-view="home">Home</a>
|
||||||
<a href="/compare" class="nav-link" data-view="compare">Compare</a>
|
<a href="/compare" class="nav-link" data-view="compare">Compare</a>
|
||||||
<a href="/rankings" class="nav-link" data-view="rankings">Rankings</a>
|
<a href="/rankings" class="nav-link" data-view="rankings">Rankings</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -83,8 +91,8 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<!-- Dashboard View -->
|
<!-- Home View -->
|
||||||
<section id="dashboard-view" class="view active">
|
<section id="home-view" class="view active">
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1 class="hero-title">Compare Primary School Performance</h1>
|
<h1 class="hero-title">Compare Primary School Performance</h1>
|
||||||
<p class="hero-subtitle">Search and compare KS2 results across England's primary schools</p>
|
<p class="hero-subtitle">Search and compare KS2 results across England's primary schools</p>
|
||||||
@@ -132,9 +140,9 @@
|
|||||||
<div class="location-input-group">
|
<div class="location-input-group">
|
||||||
<input type="text" id="postcode-search" class="search-input postcode-input" placeholder="Enter postcode...">
|
<input type="text" id="postcode-search" class="search-input postcode-input" placeholder="Enter postcode...">
|
||||||
<select id="radius-select" class="filter-select radius-select">
|
<select id="radius-select" class="filter-select radius-select">
|
||||||
<option value="1" selected>1 mile</option>
|
<option value="0.5" selected>1/2 mile</option>
|
||||||
|
<option value="1">1 mile</option>
|
||||||
<option value="2">2 miles</option>
|
<option value="2">2 miles</option>
|
||||||
<option value="5">5 miles</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button id="location-search-btn" class="btn btn-primary location-btn">Find Nearby</button>
|
<button id="location-search-btn" class="btn btn-primary location-btn">Find Nearby</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,27 +327,134 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
<button class="btn btn-primary modal-compare-btn" id="add-to-compare">Add to Compare</button>
|
||||||
<h2 id="modal-school-name"></h2>
|
<h2 id="modal-school-name"></h2>
|
||||||
<div class="modal-meta" id="modal-meta"></div>
|
<div class="modal-meta" id="modal-meta"></div>
|
||||||
|
<div class="modal-details" id="modal-details"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="modal-chart-container">
|
<div class="modal-chart-container">
|
||||||
<canvas id="school-detail-chart"></canvas>
|
<canvas id="school-detail-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-stats" id="modal-stats"></div>
|
<div class="modal-stats" id="modal-stats"></div>
|
||||||
</div>
|
<div class="modal-map-container" id="modal-map-container">
|
||||||
<div class="modal-footer">
|
<h4>Location</h4>
|
||||||
<button class="btn btn-primary" id="add-to-compare">Add to Compare</button>
|
<div class="modal-map" id="modal-map"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p>Data source: <a href="https://www.compare-school-performance.service.gov.uk/" target="_blank">UK Government - Compare School Performance</a></p>
|
<div class="footer-content">
|
||||||
<p class="footer-note">Primary school (KS2) data for England. Data from 2019-2020, 2020-2021, 2021-2022 unavailable due to COVID-19 disruption.</p>
|
<div class="footer-contact">
|
||||||
|
<h3>Contact Us</h3>
|
||||||
|
<p>Have questions, feedback, or suggestions? We'd love to hear from you.</p>
|
||||||
|
<form action="https://formsubmit.co/contact@schoolcompare.co.uk" method="POST" class="contact-form">
|
||||||
|
<input type="hidden" name="_subject" value="SchoolCompare Contact Form">
|
||||||
|
<input type="hidden" name="_captcha" value="false">
|
||||||
|
<input type="text" name="_honey" style="display:none">
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="text" name="name" placeholder="Your Name" required class="form-input">
|
||||||
|
<input type="email" name="email" placeholder="Your Email" required class="form-input">
|
||||||
|
</div>
|
||||||
|
<textarea name="message" placeholder="Your Message" required class="form-input form-textarea"></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary">Send Message</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="footer-source">
|
||||||
|
<p>Data source: <a href="https://www.compare-school-performance.service.gov.uk/" target="_blank">UK Government - Compare School Performance</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
|
|
||||||
|
<!-- Google Analytics (loaded conditionally after consent) -->
|
||||||
|
<script>
|
||||||
|
var GA_MEASUREMENT_ID = null;
|
||||||
|
var analyticsConsentGiven = false;
|
||||||
|
|
||||||
|
function loadGoogleAnalytics() {
|
||||||
|
if (window.gaLoaded || !GA_MEASUREMENT_ID) return;
|
||||||
|
window.gaLoaded = true;
|
||||||
|
|
||||||
|
// Load gtag.js script
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.async = true;
|
||||||
|
script.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_MEASUREMENT_ID;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
// Initialize dataLayer and gtag function
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
window.gtag = gtag;
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', GA_MEASUREMENT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch GA ID from server config, then load GA if consent already given
|
||||||
|
fetch('/api/config')
|
||||||
|
.then(function(response) { return response.json(); })
|
||||||
|
.then(function(config) {
|
||||||
|
if (config.ga_measurement_id) {
|
||||||
|
GA_MEASUREMENT_ID = config.ga_measurement_id;
|
||||||
|
// If consent was already given before config loaded, load GA now
|
||||||
|
if (analyticsConsentGiven) {
|
||||||
|
loadGoogleAnalytics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) { console.warn('Failed to load config:', err); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Cookie Consent Banner -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/gh/silktide/consent-manager@main/silktide-consent-manager.js"></script>
|
||||||
|
<script>
|
||||||
|
window.silktideConsentManager.init({
|
||||||
|
consentTypes: [
|
||||||
|
{
|
||||||
|
id: 'necessary',
|
||||||
|
label: 'Necessary',
|
||||||
|
description: 'Essential cookies required for the website to function properly.',
|
||||||
|
required: true,
|
||||||
|
defaultValue: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analytics',
|
||||||
|
label: 'Analytics',
|
||||||
|
description: 'Help us understand how visitors use our site so we can improve it.',
|
||||||
|
required: false,
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
text: {
|
||||||
|
title: 'Cookie Preferences',
|
||||||
|
description: 'We use cookies to improve your experience. Analytics cookies help us understand how you use the site.',
|
||||||
|
acceptAll: 'Accept All',
|
||||||
|
rejectAll: 'Reject All',
|
||||||
|
save: 'Save Preferences'
|
||||||
|
},
|
||||||
|
onConsentChange: function(consent) {
|
||||||
|
if (consent.analytics) {
|
||||||
|
analyticsConsentGiven = true;
|
||||||
|
loadGoogleAnalytics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check existing consent state after initialization
|
||||||
|
(function() {
|
||||||
|
var manager = window.silktideConsentManager.getInstance();
|
||||||
|
if (manager) {
|
||||||
|
var analyticsConsent = manager.getConsentChoice('analytics');
|
||||||
|
if (analyticsConsent === true) {
|
||||||
|
analyticsConsentGiven = true;
|
||||||
|
loadGoogleAnalytics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -433,6 +433,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.school-card {
|
.school-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -441,6 +443,7 @@ body {
|
|||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.school-card::before {
|
.school-card::before {
|
||||||
@@ -493,18 +496,40 @@ body {
|
|||||||
color: var(--accent-teal);
|
color: var(--accent-teal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.school-tag.faith {
|
||||||
|
background: rgba(138, 43, 226, 0.1);
|
||||||
|
color: #8a2be2;
|
||||||
|
}
|
||||||
|
|
||||||
.school-address {
|
.school-address {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-details {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.age-range {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.school-stats {
|
.school-stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
@@ -512,9 +537,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 1.25rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value.positive {
|
.stat-value.positive {
|
||||||
@@ -525,11 +554,121 @@ body {
|
|||||||
color: var(--accent-coral);
|
color: var(--accent-coral);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Trend indicators */
|
||||||
|
.trend-indicator {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-up {
|
||||||
|
color: var(--accent-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-down {
|
||||||
|
color: var(--accent-coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-stable {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* School Card Map */
|
||||||
|
.school-map {
|
||||||
|
height: 150px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-map:hover {
|
||||||
|
border-color: var(--accent-coral);
|
||||||
|
box-shadow: var(--shadow-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fullscreen Map Modal */
|
||||||
|
.map-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-modal {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-large);
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-modal-header h3 {
|
||||||
|
font-family: 'Playfair Display', Georgia, serif;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-modal-close:hover {
|
||||||
|
color: var(--accent-coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-modal-content {
|
||||||
|
height: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.map-modal {
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-modal-content {
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section Titles */
|
/* Section Titles */
|
||||||
@@ -854,7 +993,7 @@ body {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 200;
|
z-index: 2000;
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -920,6 +1059,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
|
position: relative;
|
||||||
padding: 2rem 2rem 1rem;
|
padding: 2rem 2rem 1rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
@@ -930,7 +1070,7 @@ body {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
padding-right: 3rem;
|
padding-right: 10rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-meta {
|
.modal-meta {
|
||||||
@@ -939,6 +1079,26 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-details {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-details .modal-address {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-compare-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-details .modal-age-range {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
@@ -994,13 +1154,25 @@ body {
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-map-container {
|
||||||
padding: 1.5rem 2rem;
|
margin-top: 2rem;
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-map-container h4 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-map {
|
||||||
|
height: 200px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
@@ -1025,14 +1197,82 @@ body {
|
|||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.footer {
|
.footer {
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-contact {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-contact h3 {
|
||||||
|
font-family: 'Playfair Display', serif;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-contact > p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .form-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-teal);
|
||||||
|
box-shadow: 0 0 0 3px rgba(45, 106, 100, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .form-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .form-textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-source {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
.footer a {
|
.footer a {
|
||||||
color: var(--accent-teal);
|
color: var(--accent-teal);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -1042,9 +1282,14 @@ body {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-note {
|
@media (max-width: 768px) {
|
||||||
margin-top: 0.5rem;
|
.contact-form .form-row {
|
||||||
font-size: 0.75rem;
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form .btn {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading State */
|
/* Loading State */
|
||||||
@@ -1223,9 +1468,279 @@ body {
|
|||||||
max-height: calc(100vh - 2rem);
|
max-height: calc(100vh - 2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
padding-right: 0;
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-meta {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-details {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-compare-btn {
|
||||||
|
position: static;
|
||||||
|
order: 4;
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-chart-container {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-chart-container canvas {
|
||||||
|
max-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-stat {
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-stat-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-stat-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
.rankings-controls {
|
.rankings-controls {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Extra small screens */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.modal-content {
|
||||||
|
margin: 0.5rem;
|
||||||
|
max-height: calc(100vh - 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-chart-container canvas {
|
||||||
|
max-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-stat-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-map {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
TOOLTIP SYSTEM
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
/* Info Icon Trigger */
|
||||||
|
.info-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: help;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: var(--transition);
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-trigger:hover,
|
||||||
|
.info-trigger:focus {
|
||||||
|
color: var(--accent-teal);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-trigger:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-trigger:focus:not(:focus-visible) {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-trigger:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Icon SVG */
|
||||||
|
.info-icon {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-stat-label .info-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip Container */
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3000;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-align: left;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 150ms ease, visibility 150ms ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip Arrow - Top Placement (arrow points down) */
|
||||||
|
.tooltip[data-placement="top"]::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 8px solid transparent;
|
||||||
|
border-top-color: var(--bg-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip Arrow - Bottom Placement (arrow points up) */
|
||||||
|
.tooltip[data-placement="bottom"]::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 8px solid transparent;
|
||||||
|
border-bottom-color: var(--bg-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip Title */
|
||||||
|
.tooltip-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip Note (for context like national average) */
|
||||||
|
.tooltip-note {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid rgba(250, 247, 242, 0.2);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning Trigger Button */
|
||||||
|
.warning-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: help;
|
||||||
|
color: var(--accent-coral);
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: var(--transition);
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-trigger:hover,
|
||||||
|
.warning-trigger:focus {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-trigger:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-trigger:focus:not(:focus-visible) {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-trigger:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning Icon SVG */
|
||||||
|
.warning-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning Tooltip Styling */
|
||||||
|
.tooltip.tooltip-warning {
|
||||||
|
background: #8b4513;
|
||||||
|
border-left: 3px solid var(--accent-coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.tooltip-warning[data-placement="top"]::after {
|
||||||
|
border-top-color: #8b4513;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.tooltip-warning[data-placement="bottom"]::after {
|
||||||
|
border-bottom-color: #8b4513;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label wrapper for inline icon */
|
||||||
|
.stat-label-with-info {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|||||||
184
scripts/geocode_schools.py
Executable file
184
scripts/geocode_schools.py
Executable file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Geocode all school postcodes and update the database.
|
||||||
|
|
||||||
|
This script should be run as a weekly cron job to ensure all schools
|
||||||
|
have up-to-date latitude/longitude coordinates.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/geocode_schools.py [--force]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--force Re-geocode all postcodes, even if already geocoded
|
||||||
|
|
||||||
|
Crontab example (run every Sunday at 2am):
|
||||||
|
0 2 * * 0 cd /path/to/school_compare && /path/to/venv/bin/python scripts/geocode_schools.py >> /var/log/geocode_schools.log 2>&1
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Add parent directory to path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from backend.database import SessionLocal
|
||||||
|
from backend.models import School
|
||||||
|
|
||||||
|
|
||||||
|
def geocode_postcodes_bulk(postcodes: list) -> Dict[str, Tuple[float, float]]:
|
||||||
|
"""
|
||||||
|
Geocode postcodes in bulk using postcodes.io API.
|
||||||
|
Returns dict of postcode -> (latitude, longitude).
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
valid_postcodes = [
|
||||||
|
p.strip().upper()
|
||||||
|
for p in postcodes
|
||||||
|
if p and isinstance(p, str) and len(p.strip()) >= 5
|
||||||
|
]
|
||||||
|
valid_postcodes = list(set(valid_postcodes))
|
||||||
|
|
||||||
|
if not valid_postcodes:
|
||||||
|
return results
|
||||||
|
|
||||||
|
batch_size = 100
|
||||||
|
total_batches = (len(valid_postcodes) + batch_size - 1) // batch_size
|
||||||
|
|
||||||
|
for i, batch_start in enumerate(range(0, len(valid_postcodes), batch_size)):
|
||||||
|
batch = valid_postcodes[batch_start : batch_start + batch_size]
|
||||||
|
print(f" Geocoding batch {i + 1}/{total_batches} ({len(batch)} postcodes)...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
"https://api.postcodes.io/postcodes",
|
||||||
|
json={"postcodes": batch},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
for item in data.get("result", []):
|
||||||
|
if item and item.get("result"):
|
||||||
|
pc = item["query"].upper()
|
||||||
|
lat = item["result"].get("latitude")
|
||||||
|
lon = item["result"].get("longitude")
|
||||||
|
if lat and lon:
|
||||||
|
results[pc] = (lat, lon)
|
||||||
|
else:
|
||||||
|
print(f" Warning: API returned status {response.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Warning: Geocoding batch failed: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def geocode_schools(force: bool = False) -> None:
|
||||||
|
"""
|
||||||
|
Geocode all schools in the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force: If True, re-geocode all postcodes even if already geocoded
|
||||||
|
"""
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"School Geocoding Job - {datetime.now().isoformat()}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get schools that need geocoding
|
||||||
|
if force:
|
||||||
|
schools = db.query(School).filter(School.postcode.isnot(None)).all()
|
||||||
|
print(f"Force mode: Processing all {len(schools)} schools with postcodes")
|
||||||
|
else:
|
||||||
|
schools = db.query(School).filter(
|
||||||
|
School.postcode.isnot(None),
|
||||||
|
(School.latitude.is_(None)) | (School.longitude.is_(None))
|
||||||
|
).all()
|
||||||
|
print(f"Found {len(schools)} schools without coordinates")
|
||||||
|
|
||||||
|
if not schools:
|
||||||
|
print("No schools to geocode. Exiting.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract unique postcodes
|
||||||
|
postcodes = list(set(
|
||||||
|
s.postcode.strip().upper()
|
||||||
|
for s in schools
|
||||||
|
if s.postcode
|
||||||
|
))
|
||||||
|
print(f"Unique postcodes to geocode: {len(postcodes)}")
|
||||||
|
|
||||||
|
# Geocode in bulk
|
||||||
|
print("\nGeocoding postcodes...")
|
||||||
|
geocoded = geocode_postcodes_bulk(postcodes)
|
||||||
|
print(f"Successfully geocoded: {len(geocoded)} postcodes")
|
||||||
|
|
||||||
|
# Update database
|
||||||
|
print("\nUpdating database...")
|
||||||
|
updated_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
for school in schools:
|
||||||
|
if not school.postcode:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pc_upper = school.postcode.strip().upper()
|
||||||
|
coords = geocoded.get(pc_upper)
|
||||||
|
|
||||||
|
if coords:
|
||||||
|
school.latitude = coords[0]
|
||||||
|
school.longitude = coords[1]
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print(f"\nResults:")
|
||||||
|
print(f" - Updated: {updated_count} schools")
|
||||||
|
print(f" - Failed (invalid/not found): {failed_count} postcodes")
|
||||||
|
|
||||||
|
# Summary stats
|
||||||
|
total_with_coords = db.query(School).filter(
|
||||||
|
School.latitude.isnot(None),
|
||||||
|
School.longitude.isnot(None)
|
||||||
|
).count()
|
||||||
|
total_schools = db.query(School).count()
|
||||||
|
|
||||||
|
print(f"\nDatabase summary:")
|
||||||
|
print(f" - Total schools: {total_schools}")
|
||||||
|
print(f" - Schools with coordinates: {total_with_coords}")
|
||||||
|
print(f" - Coverage: {100*total_with_coords/total_schools:.1f}%")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during geocoding: {e}")
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Geocoding job completed - {datetime.now().isoformat()}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Geocode school postcodes and update database"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Re-geocode all postcodes, even if already geocoded"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
geocode_schools(force=args.force)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user