Implement story 1.4: location resolution ICAO and address
Adds location.resolve() with ICAO CSV lookup and Nominatim geocoding, POST /find-location portal route with inline confirmation/error display, and full test coverage with mocked network calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,50 @@
|
||||
# stub
|
||||
import csv
|
||||
import importlib.resources
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
||||
_USER_AGENT = "planemapper/0.1 (https://github.com/football2801/planeMapper)"
|
||||
|
||||
|
||||
def _lookup_icao(code: str) -> tuple[float, float, str] | None:
|
||||
traversable = importlib.resources.files("planemapper.data").joinpath("airports.csv")
|
||||
with traversable.open("r", encoding="utf-8") as fh:
|
||||
reader = csv.DictReader(fh)
|
||||
for row in reader:
|
||||
if row["ident"] == code:
|
||||
return float(row["latitude_deg"]), float(row["longitude_deg"]), row["name"]
|
||||
return None
|
||||
|
||||
|
||||
def _geocode(query: str) -> tuple[float, float, str] | None:
|
||||
resp = requests.get(
|
||||
NOMINATIM_URL,
|
||||
params={"q": query, "format": "json", "limit": 1},
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
results = resp.json()
|
||||
if not results:
|
||||
return None
|
||||
r = results[0]
|
||||
return float(r["lat"]), float(r["lon"]), r["display_name"]
|
||||
|
||||
|
||||
def resolve(query: str) -> tuple[float, float, str]:
|
||||
query = query.strip().upper()
|
||||
if len(query) == 4 and query.isalpha():
|
||||
result = _lookup_icao(query)
|
||||
if result is None:
|
||||
raise ValueError("ICAO code not found — try an address instead")
|
||||
log.info("ICAO %s resolved to %s", query, result[2])
|
||||
return result
|
||||
result = _geocode(query)
|
||||
if result is None:
|
||||
raise ValueError("Location not found — try a different search term")
|
||||
log.info("Address '%s' resolved to %s", query, result[2])
|
||||
return result
|
||||
|
||||
@@ -1,29 +1,54 @@
|
||||
import logging
|
||||
|
||||
from flask import Flask, redirect, url_for
|
||||
from flask import Flask, redirect, request, url_for
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from planemapper.provisioning import location
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
_FORM_HTML = """<!DOCTYPE html>
|
||||
|
||||
def _build_form_html(
|
||||
error: str = "",
|
||||
resolved_name: str = "",
|
||||
resolved_lat: str = "",
|
||||
resolved_lon: str = "",
|
||||
radius: str = "100",
|
||||
) -> str:
|
||||
error_html = f'<p style="color:red">{error}</p>' if error else ""
|
||||
confirmed_html = ""
|
||||
hidden_fields = ""
|
||||
if resolved_name:
|
||||
confirmed_html = f"""
|
||||
<div style="background:#f0f0f0;padding:8px;margin:8px 0">
|
||||
<strong>Confirmed:</strong> {resolved_name}<br>
|
||||
Lat: {resolved_lat}, Lon: {resolved_lon}
|
||||
</div>"""
|
||||
hidden_fields = f"""
|
||||
<input type="hidden" name="confirmed_lat" value="{resolved_lat}">
|
||||
<input type="hidden" name="confirmed_lon" value="{resolved_lon}">
|
||||
<input type="hidden" name="confirmed_name" value="{resolved_name}">"""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>planeMapper Setup</title></head>
|
||||
<body>
|
||||
<h1>planeMapper Setup</h1>
|
||||
{error_html}
|
||||
<form method="POST" action="/find-location">
|
||||
<label>Location (ICAO code or address/postcode):<br>
|
||||
<input type="text" name="location" required>
|
||||
</label><br><br>
|
||||
<label>Coverage radius (nm):<br>
|
||||
<input type="number" name="radius" value="100" min="10" max="500">
|
||||
<input type="number" name="radius" value="{radius}" min="10" max="500">
|
||||
</label><br><br>
|
||||
<button type="submit">Find location</button>
|
||||
</form>
|
||||
{confirmed_html}
|
||||
<hr>
|
||||
<form method="POST" action="/submit">
|
||||
<label>Confirmed location: <span id="confirmed-location">Not yet confirmed</span></label><br><br>
|
||||
{hidden_fields}
|
||||
<label>Home WiFi SSID:<br>
|
||||
<input type="text" name="wifi_ssid" required>
|
||||
</label><br><br>
|
||||
@@ -38,7 +63,32 @@ _FORM_HTML = """<!DOCTYPE html>
|
||||
|
||||
@app.route("/")
|
||||
def index() -> str:
|
||||
return _FORM_HTML
|
||||
return _build_form_html()
|
||||
|
||||
|
||||
@app.route("/find-location", methods=["POST"])
|
||||
def find_location() -> str:
|
||||
query = request.form.get("location", "").strip()
|
||||
radius = request.form.get("radius", "100")
|
||||
error_msg = ""
|
||||
resolved_name = ""
|
||||
resolved_lat = ""
|
||||
resolved_lon = ""
|
||||
if query:
|
||||
try:
|
||||
lat, lon, name = location.resolve(query)
|
||||
resolved_lat = str(lat)
|
||||
resolved_lon = str(lon)
|
||||
resolved_name = name
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
return _build_form_html(
|
||||
error=error_msg,
|
||||
resolved_name=resolved_name,
|
||||
resolved_lat=resolved_lat,
|
||||
resolved_lon=resolved_lon,
|
||||
radius=radius,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/generate_204")
|
||||
|
||||
Reference in New Issue
Block a user