185 lines
5.5 KiB
Python
185 lines
5.5 KiB
Python
|
|
#!/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()
|