Add running route planner with quality comparison and GPX export
This commit is contained in:
@@ -80,6 +80,13 @@ Cards show metric definitions and usable-run coverage. Select a chart point or
|
||||
group row to inspect contributing runs. Save named views in this browser, or
|
||||
bookmark the results URL; relative queries follow the calendar as runs are added.
|
||||
|
||||
### Planning running routes
|
||||
|
||||
Open **Routes** to plan loops or point-to-point runs with a target distance,
|
||||
hill difficulty and surface preference. Compare route maps, elevation profiles
|
||||
and quality coverage; save routes privately and export GPX. Live generation
|
||||
requires a server-side `ORS_API_KEY`. See [route planner setup and limitations](docs/ROUTE_PLANNER.md).
|
||||
|
||||
### Bringing in more activities
|
||||
|
||||
Use **Settings & imports -> Connect Garmin** to upload an exported session, then
|
||||
|
||||
@@ -26,9 +26,14 @@ def create_app(config=None):
|
||||
LOCAL_SETUP=not production,
|
||||
DATA_ROOT=os.environ.get("DATA_ROOT", str(ROOT / "data" / "garmin")),
|
||||
TOKEN_ENCRYPTION_KEY=os.environ.get("TOKEN_ENCRYPTION_KEY"),
|
||||
ORS_API_KEY=os.environ.get("ORS_API_KEY"),
|
||||
RATELIMIT_STORAGE_URI=os.environ.get("RATELIMIT_STORAGE_URI", "memory://"),
|
||||
)
|
||||
app.config.update(config or {})
|
||||
if not app.config.get("ORS_API_KEY") and not production:
|
||||
routing_key = Path(app.instance_path) / "ors-api-key"
|
||||
if routing_key.is_file():
|
||||
app.config["ORS_API_KEY"] = routing_key.read_text(encoding="utf-8").strip()
|
||||
if production:
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
@@ -64,6 +69,9 @@ def create_app(config=None):
|
||||
from .routes import web
|
||||
|
||||
app.register_blueprint(web)
|
||||
from .planner import planner
|
||||
|
||||
app.register_blueprint(planner)
|
||||
from .sync import register_cli
|
||||
|
||||
register_cli(app)
|
||||
|
||||
@@ -90,6 +90,17 @@ class Activity(db.Model):
|
||||
return f"{seconds // 60}:{seconds % 60:02d}"
|
||||
|
||||
|
||||
class PlannedRoute(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
|
||||
title = db.Column(db.String(160), nullable=False)
|
||||
saved = db.Column(db.Boolean, nullable=False, default=False)
|
||||
payload = db.Column(db.JSON, nullable=False)
|
||||
created_at = db.Column(
|
||||
db.DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
class Friendship(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
low_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||
|
||||
from flask import Blueprint, Response, abort, current_app, jsonify, render_template, request
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from .extensions import db, limiter
|
||||
from .models import PlannedRoute
|
||||
from .planner_engine import PlannerError, generate, validate
|
||||
|
||||
planner = Blueprint("planner", __name__, url_prefix="/routes")
|
||||
|
||||
|
||||
def owned(route_id, saved_only=False):
|
||||
route = db.get_or_404(PlannedRoute, route_id)
|
||||
if route.owner_id != current_user.id or (saved_only and not route.saved):
|
||||
abort(404)
|
||||
return route
|
||||
|
||||
|
||||
def serialise(route):
|
||||
return dict(id=route.id, title=route.title, saved=route.saved, **route.payload)
|
||||
|
||||
|
||||
@planner.get("")
|
||||
@login_required
|
||||
def index():
|
||||
saved = db.session.scalars(
|
||||
db.select(PlannedRoute)
|
||||
.where(PlannedRoute.owner_id == current_user.id, PlannedRoute.saved.is_(True))
|
||||
.order_by(PlannedRoute.created_at.desc(), PlannedRoute.id.desc())
|
||||
.limit(100)
|
||||
).all()
|
||||
return render_template(
|
||||
"planner.html", configured=bool(current_app.config.get("ORS_API_KEY")), saved_routes=saved
|
||||
)
|
||||
|
||||
|
||||
@planner.post("/generate")
|
||||
@login_required
|
||||
@limiter.limit("6 per minute; 60 per day")
|
||||
def candidates():
|
||||
try:
|
||||
preferences = validate(request.get_json(silent=True))
|
||||
routes, warnings = generate(preferences, current_app.config.get("ORS_API_KEY"))
|
||||
except PlannerError as error:
|
||||
return jsonify(error=str(error)), 400
|
||||
cutoff = datetime.now(UTC) - timedelta(days=1)
|
||||
db.session.execute(
|
||||
db.delete(PlannedRoute).where(
|
||||
PlannedRoute.owner_id == current_user.id,
|
||||
PlannedRoute.saved.is_(False),
|
||||
PlannedRoute.created_at < cutoff,
|
||||
)
|
||||
)
|
||||
rows = []
|
||||
for route in routes:
|
||||
row = PlannedRoute(owner_id=current_user.id, title=route["label"], payload=route)
|
||||
db.session.add(row)
|
||||
rows.append(row)
|
||||
db.session.commit()
|
||||
return jsonify(routes=[serialise(row) for row in rows], warnings=warnings)
|
||||
|
||||
|
||||
@planner.get("/<int:route_id>")
|
||||
@login_required
|
||||
def detail(route_id):
|
||||
return jsonify(serialise(owned(route_id, saved_only=True)))
|
||||
|
||||
|
||||
@planner.post("/<int:route_id>/save")
|
||||
@login_required
|
||||
def save(route_id):
|
||||
row = owned(route_id)
|
||||
data = request.get_json(silent=True)
|
||||
title = data.get("title") if isinstance(data, dict) else None
|
||||
if not isinstance(title, str) or not 1 <= len(title.strip()) <= 160:
|
||||
return jsonify(error="Name your route using 1–160 characters."), 400
|
||||
row.title = title.strip()
|
||||
row.saved = True
|
||||
db.session.commit()
|
||||
return jsonify(serialise(row))
|
||||
|
||||
|
||||
@planner.post("/<int:route_id>/delete")
|
||||
@login_required
|
||||
def delete(route_id):
|
||||
db.session.delete(owned(route_id, saved_only=True))
|
||||
db.session.commit()
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@planner.get("/<int:route_id>/gpx")
|
||||
@login_required
|
||||
def export(route_id):
|
||||
row = owned(route_id, saved_only=True)
|
||||
root = Element(
|
||||
"gpx", {"version": "1.1", "creator": "Stride", "xmlns": "http://www.topografix.com/GPX/1/1"}
|
||||
)
|
||||
track = SubElement(root, "trk")
|
||||
SubElement(track, "name").text = row.title
|
||||
segment = SubElement(track, "trkseg")
|
||||
for coordinate in row.payload["coordinates"]:
|
||||
point = SubElement(segment, "trkpt", {"lat": str(coordinate[1]), "lon": str(coordinate[0])})
|
||||
if len(coordinate) == 3:
|
||||
SubElement(point, "ele").text = str(coordinate[2])
|
||||
return Response(
|
||||
tostring(root, encoding="utf-8", xml_declaration=True),
|
||||
mimetype="application/gpx+xml",
|
||||
headers={"Content-Disposition": f'attachment; filename="stride-route-{row.id}.gpx"'},
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Bounded pedestrian route generation and explainable ranking, using ORS GeoJSON."""
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
from collections import Counter
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
class PlannerError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def number(value, low, high, label):
|
||||
if isinstance(value, bool):
|
||||
raise PlannerError(f"Choose a valid {label}.")
|
||||
try:
|
||||
value = float(value)
|
||||
except (ValueError, TypeError):
|
||||
raise PlannerError(f"Choose a valid {label}.") from None
|
||||
if not math.isfinite(value) or not low <= value <= high:
|
||||
raise PlannerError(f"{label.capitalize()} must be between {low} and {high}.")
|
||||
return value
|
||||
|
||||
|
||||
def point(value):
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
raise PlannerError("Select a location on the map or enter latitude, longitude.")
|
||||
return [number(value[0], -180, 180, "longitude"), number(value[1], -85, 85, "latitude")]
|
||||
|
||||
|
||||
def validate(data):
|
||||
if not isinstance(data, dict):
|
||||
raise PlannerError("Supply route preferences.")
|
||||
mode = data.get("mode", "loop")
|
||||
hill = data.get("hill", "rolling")
|
||||
surface = data.get("surface", "any")
|
||||
priority = data.get("priority", "quality")
|
||||
if not all(isinstance(value, str) for value in (mode, hill, surface, priority)):
|
||||
raise PlannerError("Choose valid route preferences.")
|
||||
if mode not in {"loop", "point"} or hill not in {"flat", "rolling", "hilly"}:
|
||||
raise PlannerError("Choose a valid route type and hill difficulty.")
|
||||
if surface not in {"any", "paved", "trails"} or priority not in {
|
||||
"quality",
|
||||
"distance",
|
||||
"hills",
|
||||
}:
|
||||
raise PlannerError("Choose valid route preferences.")
|
||||
start = point(data.get("start"))
|
||||
end = start if mode == "loop" else point(data.get("end"))
|
||||
distance = number(data.get("distance"), 1, 50, "distance (km)") * 1000
|
||||
if not isinstance(data.get("avoid_repeats", True), bool):
|
||||
raise PlannerError("Choose whether to minimise repeated sections.")
|
||||
if mode == "point" and metres(start, end) > distance * 1.05:
|
||||
raise PlannerError("Your target distance is shorter than the straight-line distance.")
|
||||
return {
|
||||
"mode": mode,
|
||||
"hill": hill,
|
||||
"surface": surface,
|
||||
"priority": priority,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"distance": distance,
|
||||
"avoid_repeats": bool(data.get("avoid_repeats", True)),
|
||||
}
|
||||
|
||||
|
||||
def metres(a, b):
|
||||
lat1, lat2 = math.radians(a[1]), math.radians(b[1])
|
||||
dlat, dlon = lat2 - lat1, math.radians(b[0] - a[0])
|
||||
value = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
||||
return 12742000 * math.asin(math.sqrt(min(1, value)))
|
||||
|
||||
|
||||
def destination(origin, distance, bearing):
|
||||
lat, lon = map(math.radians, [origin[1], origin[0]])
|
||||
angle, bearing = distance / 6371000, math.radians(bearing)
|
||||
target = math.asin(
|
||||
math.sin(lat) * math.cos(angle) + math.cos(lat) * math.sin(angle) * math.cos(bearing)
|
||||
)
|
||||
longitude = lon + math.atan2(
|
||||
math.sin(bearing) * math.sin(angle) * math.cos(lat),
|
||||
math.cos(angle) - math.sin(lat) * math.sin(target),
|
||||
)
|
||||
return [(math.degrees(longitude) + 180) % 360 - 180, math.degrees(target)]
|
||||
|
||||
|
||||
def provider_request(body, key):
|
||||
request = Request(
|
||||
"https://api.openrouteservice.org/v2/directions/foot-walking/geojson",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={"Authorization": key, "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=12) as response:
|
||||
raw = response.read(4_000_001)
|
||||
if len(raw) > 4_000_000:
|
||||
raise PlannerError("Routing returned too much data. Try a shorter route.")
|
||||
result = json.loads(raw)
|
||||
if not isinstance(result, dict) or not isinstance(result.get("features"), list):
|
||||
raise PlannerError("The routing service returned an incomplete response.")
|
||||
return result
|
||||
except HTTPError as error:
|
||||
if error.code in (401, 403):
|
||||
raise PlannerError(
|
||||
"The routing service key was rejected. Contact the site operator."
|
||||
) from None
|
||||
if error.code == 429:
|
||||
raise PlannerError("The routing service is busy. Please try again later.") from None
|
||||
raise PlannerError("No route was found for these locations. Try nearby paths.") from None
|
||||
except (URLError, TimeoutError, OSError, json.JSONDecodeError):
|
||||
raise PlannerError("The routing service could not be reached. Please try again.") from None
|
||||
|
||||
|
||||
def coverage(extras, name, codes, total):
|
||||
if not isinstance(extras, dict) or not isinstance(extras.get(name, {}), dict):
|
||||
raise PlannerError("The routing service returned invalid quality data.")
|
||||
summary = extras.get(name, {}).get("summary", [])
|
||||
if not summary:
|
||||
return None
|
||||
if not isinstance(summary, list) or any(not isinstance(row, dict) for row in summary):
|
||||
raise PlannerError("The routing service returned invalid quality data.")
|
||||
distance = sum(
|
||||
number(row.get("distance", 0), 0, 200000, "quality distance")
|
||||
for row in summary
|
||||
if isinstance(row.get("value"), (int, float))
|
||||
and not isinstance(row.get("value"), bool)
|
||||
and row.get("value") in codes
|
||||
)
|
||||
return round(min(100, distance / total * 100), 1)
|
||||
|
||||
|
||||
def analyse(feature, preferences):
|
||||
if not isinstance(feature, dict) or not isinstance(feature.get("geometry"), dict):
|
||||
raise PlannerError("The routing service returned invalid route data.")
|
||||
geometry = feature.get("geometry", {})
|
||||
if geometry.get("type") != "LineString":
|
||||
raise PlannerError("The routing service returned an unsupported route.")
|
||||
coordinates = geometry.get("coordinates", [])
|
||||
if not isinstance(coordinates, list) or not 2 <= len(coordinates) <= 50000:
|
||||
raise PlannerError("The routing service returned an incomplete route.")
|
||||
clean = []
|
||||
for coordinate in coordinates:
|
||||
if not isinstance(coordinate, list) or len(coordinate) not in (2, 3):
|
||||
raise PlannerError("The routing service returned invalid coordinates.")
|
||||
xy = point(coordinate[:2])
|
||||
if len(coordinate) == 3:
|
||||
xy.append(number(coordinate[2], -12000, 12000, "elevation"))
|
||||
clean.append(xy)
|
||||
props = feature.get("properties", {})
|
||||
if not isinstance(props, dict) or not isinstance(props.get("summary"), dict):
|
||||
raise PlannerError("The routing service returned an incomplete summary.")
|
||||
summary = props.get("summary", {})
|
||||
distance = number(summary.get("distance"), 1, 200000, "route distance")
|
||||
ascent = props.get("ascent", summary.get("ascent"))
|
||||
ascent = number(ascent, 0, 20000, "ascent") if ascent is not None else None
|
||||
extras = props.get("extras", {})
|
||||
paths = coverage(extras, "waytype", {4, 5, 6, 7}, distance)
|
||||
roads = coverage(extras, "waytype", {1, 2, 3}, distance)
|
||||
paved = coverage(extras, "surface", {1, 3, 4, 5, 6, 14}, distance)
|
||||
unknown = coverage(extras, "surface", {0}, distance)
|
||||
if unknown is not None:
|
||||
known = coverage(extras, "surface", set(range(1, 19)), distance)
|
||||
unknown = round(max(unknown, 100 - (known or 0)), 1)
|
||||
# Approximate repeated edges, irrespective of travel direction; not a traffic/safety score.
|
||||
seen = Counter()
|
||||
repeated = 0
|
||||
travelled = 0
|
||||
profile = [[0, clean[0][2]]] if len(clean[0]) == 3 else []
|
||||
for a, b in itertools.pairwise(clean):
|
||||
length = metres(a, b)
|
||||
edge = tuple(sorted((tuple(round(x, 5) for x in a[:2]), tuple(round(x, 5) for x in b[:2]))))
|
||||
if seen[edge]:
|
||||
repeated += length
|
||||
seen[edge] += 1
|
||||
travelled += length
|
||||
if len(b) == 3:
|
||||
profile.append([travelled, b[2]])
|
||||
if len(profile) != len(clean):
|
||||
profile = []
|
||||
elif travelled:
|
||||
profile = [[round(p[0] / travelled * distance, 1), p[1]] for p in profile]
|
||||
sampled_profile = profile[:: max(1, math.ceil(len(profile) / 600))]
|
||||
if profile and sampled_profile[-1] != profile[-1]:
|
||||
sampled_profile.append(profile[-1])
|
||||
repeats = round(100 * repeated / travelled, 1) if travelled else 0
|
||||
deviation = (distance - preferences["distance"]) / preferences["distance"] * 100
|
||||
climb_target = {"flat": 3, "rolling": 12, "hilly": 25}[preferences["hill"]]
|
||||
hill_penalty = abs(ascent / (distance / 1000) - climb_target) if ascent is not None else 25
|
||||
quality_penalty = (roads if roads is not None else 50) / 5
|
||||
quality_penalty += (unknown if unknown is not None else 100) / 10
|
||||
if preferences["avoid_repeats"]:
|
||||
quality_penalty += repeats / 3
|
||||
if preferences["surface"] == "paved":
|
||||
quality_penalty += (100 - paved if paved is not None else 100) / 5
|
||||
if preferences["surface"] == "trails":
|
||||
quality_penalty += (100 - paths if paths is not None else 100) / 5
|
||||
score = abs(deviation) * (3 if preferences["priority"] == "distance" else 1)
|
||||
score += hill_penalty * (2 if preferences["priority"] == "hills" else 0.5)
|
||||
score += quality_penalty * (2 if preferences["priority"] == "quality" else 0.5)
|
||||
return {
|
||||
"coordinates": clean,
|
||||
"distance_m": distance,
|
||||
"ascent_m": ascent,
|
||||
"profile": sampled_profile,
|
||||
"quality": {
|
||||
"paths_pct": paths,
|
||||
"roads_pct": roads,
|
||||
"paved_pct": paved,
|
||||
"unknown_surface_pct": unknown,
|
||||
"repeats_pct": repeats,
|
||||
},
|
||||
"deviation_pct": round(deviation, 1),
|
||||
"within_target": abs(deviation) <= 5,
|
||||
"preferences": preferences,
|
||||
"rank_cost": score,
|
||||
}
|
||||
|
||||
|
||||
def generate(preferences, key):
|
||||
if not key:
|
||||
raise PlannerError("Route generation needs ORS_API_KEY configured by the site operator.")
|
||||
routes, errors, signatures = [], [], set()
|
||||
for index in range(3):
|
||||
body = {
|
||||
"elevation": True,
|
||||
"instructions": False,
|
||||
"extra_info": ["surface", "waytype"],
|
||||
"coordinates": [preferences["start"]],
|
||||
}
|
||||
if preferences["mode"] == "loop":
|
||||
body["options"] = {
|
||||
"round_trip": {"length": preferences["distance"], "points": 3, "seed": index + 1}
|
||||
}
|
||||
else:
|
||||
start, end = preferences["start"], preferences["end"]
|
||||
body["coordinates"] = [start, end]
|
||||
if index:
|
||||
separation = metres(start, end)
|
||||
midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
|
||||
offset = (
|
||||
math.sqrt(max(0, (preferences["distance"] / 2) ** 2 - (separation / 2) ** 2))
|
||||
* 0.65
|
||||
)
|
||||
bearing = math.degrees(
|
||||
math.atan2(
|
||||
(end[0] - start[0]) * math.cos(math.radians(midpoint[1])), end[1] - start[1]
|
||||
)
|
||||
)
|
||||
waypoint = destination(
|
||||
midpoint, max(100, offset), bearing + (90 if index == 1 else -90)
|
||||
)
|
||||
body["coordinates"] = [start, waypoint, end]
|
||||
try:
|
||||
result = provider_request(body, key)
|
||||
for feature in result.get("features", [])[:1]:
|
||||
route = analyse(feature, preferences)
|
||||
signature = json.dumps(route["coordinates"])
|
||||
if signature not in signatures:
|
||||
signatures.add(signature)
|
||||
routes.append(route)
|
||||
except PlannerError as error:
|
||||
errors.append(str(error))
|
||||
if not routes:
|
||||
raise PlannerError(errors[0] if errors else "No routes were found. Try a different start.")
|
||||
routes.sort(key=lambda route: route["rank_cost"])
|
||||
for index, route in enumerate(routes):
|
||||
route["label"] = "Best match" if index == 0 else f"Alternative {index + 1}"
|
||||
return routes, errors
|
||||
@@ -0,0 +1,3 @@
|
||||
.planner-controls{display:grid;grid-template-columns:1.25fr 1fr 1fr .9fr;gap:24px;padding:24px}.planner-controls label,#save-route-form label{display:block;margin:12px 0 7px;font-size:13px}.planner-controls input:not([type=checkbox]),.planner-controls select,#route-name{width:100%;padding:11px;border:1px solid var(--line);border-radius:5px;background:var(--paper);color:var(--ink);font:inherit}.planner-controls input[type=checkbox]{width:auto;accent-color:var(--green)}.planner-controls .text-button{display:block;margin-top:8px}.planner-submit{display:flex;flex-direction:column;justify-content:flex-end;gap:14px}.planner-check{display:flex!important;align-items:center;gap:8px}.planner-controls [hidden],#route-planner [hidden]{display:none!important}#planner-status{margin:18px 0;min-height:24px}#planner-map{height:420px;z-index:0;border-radius:8px;background:var(--line)}.planner-table-wrap{overflow:auto;margin:24px 0}.planner-table{width:100%;border-collapse:collapse;text-align:left}.planner-table th{font-size:12px;color:var(--muted);font-weight:400}.planner-table td,.planner-table th{padding:15px 12px;border-bottom:1px solid var(--line);white-space:nowrap}.planner-table tr.selected{background:var(--white)}.planner-detail{display:grid;grid-template-columns:1fr 1fr;gap:30px;padding:24px}.planner-profile{height:180px;margin-top:20px}.planner-detail dl{margin:18px 0}.planner-detail dl div{display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--line)}.planner-detail dt{color:var(--muted)}.planner-detail dd{margin:0}.planner-save-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}.planner-library{padding:24px;margin-top:24px}#saved-routes{display:flex;gap:12px;flex-wrap:wrap;margin-top:16px}#planner-results button[aria-pressed=true]{border-color:var(--green)}#generate-routes:disabled{opacity:.5;cursor:wait}@media(max-width:950px){.planner-controls{grid-template-columns:1fr 1fr}}@media(max-width:680px){.planner-controls,.planner-detail{grid-template-columns:1fr;padding:18px;gap:16px}#planner-map{height:330px}.planner-controls input,.planner-controls select,#route-name{font-size:16px}}
|
||||
|
||||
.planner-controls,.planner-detail,.planner-library{background:var(--white);border:1px solid var(--line);border-radius:var(--radius)}.planner-table tr.selected{background:var(--white)}.planner-library .button{max-width:100%;white-space:normal;overflow-wrap:anywhere}.app-header .stats-nav{flex-shrink:0;color:var(--muted);font-size:13px}.app-header .stats-nav.active{color:var(--green)}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
const root = document.getElementById("route-planner");
|
||||
if (!root || !window.L) return;
|
||||
const el = id => document.getElementById(id);
|
||||
const base = root.dataset.base;
|
||||
const status = el("planner-status");
|
||||
const map = L.map("planner-map").setView([-33.8688, 151.2093], 12);
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}).addTo(map);
|
||||
const routeLayer = L.layerGroup().addTo(map);
|
||||
const markers = {};
|
||||
let picking = "start", routes = [], selected = null, chart = null;
|
||||
let generation = 0, busy = false;
|
||||
const colour = "#ff9268";
|
||||
const formatPct = value => value == null ? "Unknown" : `${value.toFixed(1)}%`;
|
||||
const formatAscent = value => value == null ? "Unknown" : `${Math.round(value)} m`;
|
||||
function coordinate(id) {
|
||||
const parts = el(id).value.split(",").map(v => v.trim());
|
||||
if (parts.length !== 2 || parts.some(v => v === "")) throw new Error("Enter latitude, longitude or select a point on the map.");
|
||||
const [lat, lon] = parts.map(Number);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 85 || Math.abs(lon) > 180) throw new Error("Coordinates are outside the supported range.");
|
||||
return [lon, lat];
|
||||
}
|
||||
function mark(kind, latlng) {
|
||||
if (markers[kind]) map.removeLayer(markers[kind]);
|
||||
markers[kind] = L.marker(latlng).addTo(map).bindTooltip(kind === "start" ? "Start" : "Finish");
|
||||
el(`plan-${kind}`).value = `${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
|
||||
}
|
||||
function setMode() {
|
||||
const point = el("plan-mode").value === "point";
|
||||
el("finish-fields").hidden = !point; el("plan-end").required = point;
|
||||
if (!point) { picking = "start"; if (markers.end) {map.removeLayer(markers.end); delete markers.end;} }
|
||||
}
|
||||
el("plan-mode").onchange = setMode;
|
||||
el("pick-start").onclick = () => {picking = "start"; status.textContent = "Click the map to set your start.";};
|
||||
el("pick-end").onclick = () => {picking = "end"; status.textContent = "Click the map to set your finish.";};
|
||||
map.on("click", e => {mark(picking, e.latlng); status.textContent = `${picking === "start" ? "Start" : "Finish"} selected.`;
|
||||
if (picking === "start" && el("plan-mode").value === "point") picking = "end";});
|
||||
el("my-location").onclick = () => {
|
||||
if (!navigator.geolocation) {status.textContent = "Location is unavailable. Select a point on the map."; return;}
|
||||
navigator.geolocation.getCurrentPosition(position => {
|
||||
const p = L.latLng(position.coords.latitude, position.coords.longitude);
|
||||
mark("start", p); map.setView(p, 15); status.textContent = "Your location is set as the start.";
|
||||
}, () => {status.textContent = "Location could not be obtained. Select a point on the map.";}, {timeout: 10000});
|
||||
};
|
||||
async function api(path, data) {
|
||||
const options = data === undefined ? {} : {method: "POST", headers: {"Content-Type": "application/json", "X-CSRFToken": root.dataset.csrf}, body: JSON.stringify(data)};
|
||||
const response = await fetch(`${base}${path}`, options);
|
||||
if (response.redirected) throw new Error("Your session expired. Sign in again.");
|
||||
let payload;
|
||||
try {payload = await response.json();} catch (_) {throw new Error(response.status === 429 ? "Too many requests. Please wait before trying again." : "The request failed. Refresh the page and try again.");}
|
||||
if (!response.ok) throw new Error(payload.error || "The request failed.");
|
||||
return payload;
|
||||
}
|
||||
function cell(row, text) {const td = document.createElement("td"); td.textContent = text; row.append(td); return td;}
|
||||
function drawOptions() {
|
||||
el("route-options").replaceChildren();
|
||||
for (const route of routes) {
|
||||
const row = document.createElement("tr"); if (selected?.id === route.id) row.className = "selected";
|
||||
cell(row, route.saved ? route.title : route.label); cell(row, `${(route.distance_m / 1000).toFixed(2)} km`);
|
||||
cell(row, formatAscent(route.ascent_m)); cell(row, formatPct(route.quality.paths_pct)); cell(row, formatPct(route.quality.repeats_pct));
|
||||
const button = document.createElement("button"); button.type = "button"; button.className = "button secondary";
|
||||
button.textContent = selected?.id === route.id ? "Selected" : "View";
|
||||
button.setAttribute("aria-pressed", String(selected?.id === route.id)); button.onclick = () => show(route);
|
||||
cell(row, "").append(button); el("route-options").append(row);
|
||||
}
|
||||
}
|
||||
function show(route) {
|
||||
selected = route; el("planner-results").hidden = false; drawOptions(); routeLayer.clearLayers();
|
||||
const points = route.coordinates.map(c => [c[1], c[0]]);
|
||||
const line = L.polyline(points, {color: colour, weight: 5}).addTo(routeLayer);
|
||||
L.circleMarker(points[0], {radius: 7, color: "#70b992", fillOpacity: 1}).bindTooltip("Route start").addTo(routeLayer);
|
||||
L.circleMarker(points.at(-1), {radius: 5, color: colour, fillOpacity: 1}).bindTooltip("Route finish").addTo(routeLayer);
|
||||
map.fitBounds(line.getBounds(), {padding: [30, 30]});
|
||||
el("route-heading").textContent = route.saved ? route.title : route.label;
|
||||
el("route-name").value = route.saved ? route.title : `${(route.distance_m / 1000).toFixed(1)} km ${route.preferences.mode === "loop" ? "loop" : "run"}`;
|
||||
el("distance-status").textContent = `${route.within_target ? "Within" : "Outside"} the ±5% distance target · ${route.deviation_pct > 0 ? "+" : ""}${route.deviation_pct}% · total ascent ${formatAscent(route.ascent_m)}.`;
|
||||
const q = route.quality; el("quality-details").replaceChildren();
|
||||
for (const [name, value] of [["Paths, tracks and footways", q.paths_pct], ["Mapped road / street share", q.roads_pct], ["Paved surface", q.paved_pct], ["Unknown surface", q.unknown_surface_pct], ["Repeated distance (estimate)", q.repeats_pct]]) {
|
||||
const div = document.createElement("div"), dt = document.createElement("dt"), dd = document.createElement("dd");
|
||||
dt.textContent = name; dd.textContent = formatPct(value); div.append(dt, dd); el("quality-details").append(div);
|
||||
}
|
||||
if (chart) chart.destroy(); chart = null;
|
||||
el("route-profile").hidden = !route.profile.length;
|
||||
el("profile-status").textContent = route.profile.length ? "Estimated elevation from routing data." : "Elevation profile unavailable for this route.";
|
||||
if (route.profile.length && window.Chart) chart = new Chart(el("route-profile"), {type: "line", data: {datasets: [{data: route.profile.map(p => ({x: p[0] / 1000, y: p[1]})), borderColor: colour, pointRadius: 0, borderWidth: 2}]}, options: {animation: false, maintainAspectRatio: false, plugins: {legend: {display: false}}, scales: {x: {type: "linear", title: {display: true, text: "Distance (km)"}}, y: {title: {display: true, text: "Elevation (m)"}}}}});
|
||||
el("route-export").hidden = !route.saved; el("route-export").href = `${base}/${route.id}/gpx`;
|
||||
el("delete-route").hidden = !route.saved;
|
||||
}
|
||||
el("planner-form").onsubmit = async e => {
|
||||
e.preventDefault(); if (busy) return;
|
||||
try {
|
||||
const start = coordinate("plan-start"), mode = el("plan-mode").value;
|
||||
const data = {start, mode, end: mode === "point" ? coordinate("plan-end") : start, distance: Number(el("plan-distance").value), hill: el("plan-hill").value, surface: el("plan-surface").value, priority: el("plan-priority").value, avoid_repeats: el("plan-repeats").checked};
|
||||
busy = true; const token = ++generation; el("generate-routes").disabled = true; status.textContent = "Finding and comparing walking routes…";
|
||||
const result = await api("/generate", data); if (token !== generation) return;
|
||||
routes = result.routes; show(routes[0]); status.textContent = `${routes.length} distinct route${routes.length === 1 ? "" : "s"} found. Best match balances your distance, hill and quality preferences.${result.warnings.length ? " Some alternatives could not be generated." : ""}`;
|
||||
} catch (error) {status.textContent = error.message;} finally {busy = false; el("generate-routes").disabled = false;}
|
||||
};
|
||||
function savedButton(route) {
|
||||
el("empty-saved")?.remove(); let button = el("saved-routes").querySelector(`[data-saved="${route.id}"]`);
|
||||
if (!button) {button = document.createElement("button"); button.type = "button"; button.className = "button secondary"; button.dataset.saved = route.id; el("saved-routes").prepend(button);}
|
||||
button.textContent = route.title;
|
||||
}
|
||||
el("save-route-form").onsubmit = async e => {
|
||||
e.preventDefault(); if (!selected) return; const id = selected.id;
|
||||
const button = e.target.querySelector("button"); button.disabled = true;
|
||||
try {const route = await api(`/${id}/save`, {title: el("route-name").value}); savedButton(route);
|
||||
routes = routes.map(r => r.id === id ? route : r); if (selected?.id === id) show(route); status.textContent = "Route saved privately.";
|
||||
} catch (error) {status.textContent = error.message;} finally {button.disabled = false;}
|
||||
};
|
||||
el("saved-routes").onclick = async e => {
|
||||
const button = e.target.closest("[data-saved]"); if (!button) return;
|
||||
const token = ++generation;
|
||||
try {const route = await api(`/${button.dataset.saved}`); if (token !== generation) return;
|
||||
routes = [route]; show(route); status.textContent = "Saved route loaded.";
|
||||
} catch (error) {status.textContent = error.message;}
|
||||
};
|
||||
el("delete-route").onclick = async () => {
|
||||
if (!selected?.saved || !window.confirm("Delete this saved route?")) return;
|
||||
const id = selected.id; el("delete-route").disabled = true;
|
||||
try {await api(`/${id}/delete`, {}); el("saved-routes").querySelector(`[data-saved="${id}"]`)?.remove();
|
||||
routes = routes.filter(r => r.id !== id); if (selected?.id === id) {selected = null; routeLayer.clearLayers(); el("planner-results").hidden = true; if (chart) chart.destroy(); chart = null;}
|
||||
status.textContent = "Saved route deleted.";
|
||||
} catch (error) {status.textContent = error.message;} finally {el("delete-route").disabled = false;}
|
||||
};
|
||||
new ResizeObserver(() => map.invalidateSize()).observe(el("planner-map")); setMode();
|
||||
})();
|
||||
@@ -14,6 +14,7 @@
|
||||
<a class="brand" href="{{ url_for('web.dashboard') }}"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
|
||||
<nav class="header-nav" aria-label="Main navigation"><a class="{{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}">Dashboard</a><a class="{{ 'active' if request.endpoint in ('web.friends', 'web.profile') }}" href="{{ url_for('web.friends') }}">Friends</a></nav>
|
||||
<a class="stats-nav {{ 'active' if request.endpoint == 'web.stats' }}" href="{{ url_for('web.stats') }}">Stats</a>
|
||||
<a class="stats-nav {{ 'active' if request.blueprint == 'planner' }}" href="{{ url_for('planner.index') }}">Routes</a>
|
||||
<div class="header-actions">{% if request.endpoint != 'web.profile' %}<a class="button primary header-import" href="{{ url_for('web.settings') }}">{{ icon('plus') }} Import run</a>{% endif %}<details class="account-dropdown"><summary class="avatar" aria-label="Account menu" title="Account menu">{{ current_user.display_name[:1]|upper }}</summary><div class="account-dropdown-menu"><strong>{{ current_user.display_name }}</strong><a href="{{ url_for('web.settings') }}">Settings & Garmin sync</a><a href="{{ url_for('web.settings') }}">Import runs</a><form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button">Sign out</button></form></div></details></div>
|
||||
</header>
|
||||
{% else %}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Route planner · Stride{% endblock %}
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static',filename='planner.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div id="route-planner" data-csrf="{{ csrf_token() }}" data-base="{{ url_for('planner.index') }}">
|
||||
<div class="page-heading"><div><span class="eyebrow">PLAN / COMPARE / RUN</span><h1>Find your next route.</h1><p>Your distance. Your hills. A route worth running.</p></div></div>
|
||||
{% if not configured %}<div class="flash">Route generation is not connected yet. The site operator needs to configure ORS_API_KEY. You can still view and export your saved routes.</div>{% endif %}
|
||||
<form id="planner-form" class="card planner-controls">
|
||||
<div><label for="plan-mode">Route type</label><select id="plan-mode"><option value="loop">Loop · finish at start</option><option value="point">Point to point</option></select>
|
||||
<label for="plan-start">Start · latitude, longitude</label><input id="plan-start" placeholder="Select on the map" required autocomplete="off"><button type="button" class="text-button" id="pick-start">Pick start on map</button>
|
||||
<div id="finish-fields" hidden><label for="plan-end">Finish · latitude, longitude</label><input id="plan-end" placeholder="Select on the map" autocomplete="off"><button type="button" class="text-button" id="pick-end">Pick finish on map</button></div>
|
||||
<button type="button" class="text-button" id="my-location">Use my location</button></div>
|
||||
<div><label for="plan-distance">Target distance (km)</label><input id="plan-distance" type="number" min="1" max="50" step="0.1" value="15" required><p class="field-help">Aim within ±5%; actual distance may vary.</p><label for="plan-hill">Hill difficulty</label><select id="plan-hill"><option value="flat">Mostly flat · ~3 m ascent/km</option><option value="rolling" selected>Rolling · ~12 m ascent/km</option><option value="hilly">Hilly · ~25 m ascent/km</option></select></div>
|
||||
<div><label for="plan-priority">Prioritise</label><select id="plan-priority"><option value="quality">Route quality</option><option value="distance">Closest distance</option><option value="hills">Hill match</option></select><label for="plan-surface">Surface preference</label><select id="plan-surface"><option value="any">Any surface</option><option value="paved">Mostly paved</option><option value="trails">Paths and tracks</option></select><label class="planner-check"><input id="plan-repeats" type="checkbox" checked> Minimise repeated sections</label></div>
|
||||
<div class="planner-submit"><button class="button primary" id="generate-routes" {% if not configured %}disabled{% endif %}>Find routes</button><p class="field-help">Locations are sent to openrouteservice for walking-path routing. Hill and quality choices rank the returned candidates.</p></div>
|
||||
</form>
|
||||
<p id="planner-status" role="status" aria-live="polite">Select a start on the map, or enter coordinates.</p>
|
||||
<div id="planner-map" aria-label="Route planner map"></div>
|
||||
<p class="field-help">Routing © openrouteservice · OpenStreetMap contributors. Mapped paths do not verify current access, traffic or lighting.</p>
|
||||
<div id="planner-results" hidden>
|
||||
<div class="planner-table-wrap"><table class="planner-table"><thead><tr><th>Suggested route</th><th>Distance</th><th>Ascent</th><th>Paths / tracks</th><th>Repeated</th><th></th></tr></thead><tbody id="route-options"></tbody></table></div>
|
||||
<div class="planner-detail card"><div><h2 id="route-heading">Route details</h2><div class="planner-profile"><canvas id="route-profile" aria-label="Elevation by distance" role="img"></canvas></div><p id="profile-status" class="field-help"></p><p id="distance-status"></p></div><div><h2>Route quality</h2><dl id="quality-details"></dl><p class="field-help">Repetition is estimated from matching recorded geometry edges. Road share describes mapped way types, not traffic levels. Missing attributes remain unknown.</p><p class="field-help">Crossings, lighting and current access: unverified.</p><form id="save-route-form"><label for="route-name">Route name</label><input id="route-name" maxlength="160" required><div class="planner-save-actions"><button class="button primary">Save route</button><a id="route-export" class="button secondary" hidden>Export GPX</a><button type="button" id="delete-route" class="button secondary" hidden>Delete</button></div></form></div></div>
|
||||
</div>
|
||||
<section class="card planner-library"><h2>Your saved routes</h2><p class="field-help">Private to your account · most recent 100 shown</p><div id="saved-routes">{% for route in saved_routes %}<button type="button" class="button secondary" data-saved="{{ route.id }}">{{ route.title }}</button>{% else %}<p id="empty-saved">No saved routes yet.</p>{% endfor %}</div></section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='planner.js') }}"></script>{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
# Route planner
|
||||
|
||||
Open **Routes** in Stride. Select the start on the map, enter latitude/longitude,
|
||||
or use your location. Choose a loop or select a separate finish, distance (1–50 km),
|
||||
hill difficulty, surface preference and ranking priority. Generated candidates show
|
||||
their actual distance, ascent, route geometry, elevation profile and quality coverage.
|
||||
Save a named route privately, reopen it, download GPX, or delete it.
|
||||
|
||||
## Configuration
|
||||
|
||||
Apply migrations with `uv run --locked flask --app app db upgrade`.
|
||||
Set `ORS_API_KEY` in the web process environment and restart the web process.
|
||||
For local development, the app also reads `instance/ors-api-key` when the environment
|
||||
key is absent. This private file is ignored by Git; production only uses environment
|
||||
configuration. Keep the file within your private Windows account/workspace.
|
||||
For Dokku, configure `ORS_API_KEY` through your normal secret-management workflow.
|
||||
The key stays server-side and is never included in rendered HTML or requests from
|
||||
the browser. Obtain a key at https://openrouteservice.org/ and review the provider's
|
||||
current quotas. The planner sends selected coordinates to the fixed ORS HTTPS
|
||||
endpoint. Browser map tiles use the app's existing OpenStreetMap tile service.
|
||||
|
||||
Without a key, generation is disabled with a configuration notice; saved routes,
|
||||
maps and export remain available. No synthetic routes are shown in the real app.
|
||||
|
||||
## Generation and ranking
|
||||
|
||||
The server makes at most three requests per generation, each with a 12-second
|
||||
network timeout and a 4 MB response limit. Requests use `foot-walking`, elevation,
|
||||
and `surface`/`waytype` extras. Loops use three deterministic round-trip seeds.
|
||||
Point-to-point candidates include a direct route and two estimated side waypoints
|
||||
to seek a longer route near the target. Duplicate geometries are removed. This
|
||||
bounded search can return fewer than three routes and cannot guarantee a distance
|
||||
or ascent target. The interface flags results outside ±5% distance.
|
||||
|
||||
Preferences rank candidates; they are not strict exclusions. Hill targets are
|
||||
approximately 3, 12 or 25 metres ascent/km. The cost combines distance deviation,
|
||||
ascent mismatch, mapped road share, missing surface coverage, optional repeated
|
||||
edges and surface preference. The priority weights distance, hills or quality
|
||||
more strongly. This is an explainable heuristic, not a safety or fitness score.
|
||||
|
||||
Quality percentages follow ORS documented waytype and surface IDs:
|
||||
- Paths/tracks: path, track, cycleway and footway.
|
||||
- Road share: state road, road and street; this does not measure traffic.
|
||||
- Paved: paved, asphalt, concrete, legacy cobblestone, metal and paving stones.
|
||||
- Missing surface data is unknown; absent extras do not become zero.
|
||||
- Repeated distance estimates subsequent traversals of the same rounded geometry
|
||||
edge in either direction. Different segmentation can miss repeated sections.
|
||||
|
||||
Crossing counts, lighting, current access and live traffic are unverified.
|
||||
GPX includes the route and available elevation, without fictional timestamps.
|
||||
No direct Garmin course upload or place-name search is included in this version.
|
||||
|
||||
## Persistence and validation
|
||||
|
||||
Candidates and saved routes are server-side and owner-scoped. Other users,
|
||||
including friends, cannot view, mutate or export them. Save and delete use CSRF
|
||||
protection. Unsaved candidates older than one day are cleaned on the owner's next
|
||||
generation. The library lists the latest 100 saved routes. Generation is limited
|
||||
to six requests/minute and 60/day using the existing limiter storage configuration.
|
||||
|
||||
Offline tests mock ORS and cover input validation, ranking, point-to-point and
|
||||
round-trip request shapes, deduplication, missing attributes, repeated sections,
|
||||
CSRF, ownership, save/delete/export and draft cleanup. Live ORS verification needs
|
||||
a configured API key; mocked tests do not establish live provider availability.
|
||||
|
||||
Live local verification on 2026-10-03 used public Centennial Park test coordinates.
|
||||
Loop and point-to-point routing each returned three distinct candidates; private
|
||||
save, reload and GPX export succeeded in a disposable in-memory test database.
|
||||
The best loop was 5,030.9 m with 61.5 m ascent and 90% mapped paths/footways.
|
||||
The local browser rendered the real route, quality figures and elevation profile
|
||||
without console errors. The suite passed 129 tests. Live provider category IDs
|
||||
arrive as decimal numbers; a regression test covers that response format.
|
||||
|
||||
Provider references:
|
||||
https://giscience.github.io/openrouteservice/api-reference/endpoints/directions/extra-info/
|
||||
https://giscience.github.io/openrouteservice/api-reference/endpoints/directions/extra-info/waytype
|
||||
https://giscience.github.io/openrouteservice/api-reference/endpoints/directions/extra-info/surface
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Private route plans and generated candidates."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "a219de4f805b"
|
||||
down_revision = "09a7c863b521"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"planned_route",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False),
|
||||
sa.Column("title", sa.String(160), nullable=False),
|
||||
sa.Column("saved", sa.Boolean(), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_planned_route_owner_id", "planned_route", ["owner_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("planned_route")
|
||||
@@ -0,0 +1,225 @@
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
from app.models import PlannedRoute, User
|
||||
from app.planner_engine import PlannerError, analyse, generate, validate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(tmp_path):
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"SQLALCHEMY_DATABASE_URI": "sqlite://",
|
||||
"SECRET_KEY": "planner-test",
|
||||
"WTF_CSRF_ENABLED": False,
|
||||
"RATELIMIT_ENABLED": False,
|
||||
"LOCAL_SETUP": False,
|
||||
"ORS_API_KEY": "test-key",
|
||||
"DATA_ROOT": str(tmp_path),
|
||||
}
|
||||
)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
for name in ("runner", "other"):
|
||||
user = User(username=name, email=f"{name}@example.test", display_name=name)
|
||||
user.set_password("a-long-test-password")
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
yield app
|
||||
|
||||
|
||||
def login(client, name="runner"):
|
||||
client.post(
|
||||
"/login", data={"email": f"{name}@example.test", "password": "a-long-test-password"}
|
||||
)
|
||||
|
||||
|
||||
def preferences():
|
||||
return {"start": [151, -33], "mode": "loop", "distance": 5, "hill": "rolling"}
|
||||
|
||||
|
||||
def feature():
|
||||
return {
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[151, -33, 10], [151.001, -33, 30], [151, -33, 10]],
|
||||
},
|
||||
"properties": {
|
||||
"summary": {"distance": 5100},
|
||||
"ascent": 20,
|
||||
"extras": {
|
||||
"waytype": {
|
||||
"summary": [{"value": 7, "distance": 4080}, {"value": 3, "distance": 1020}]
|
||||
},
|
||||
"surface": {
|
||||
"summary": [{"value": 3, "distance": 4590}, {"value": 0, "distance": 510}]
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
[
|
||||
{"distance": float("nan")},
|
||||
{"distance": 51},
|
||||
{"start": [151, 91]},
|
||||
{"start": [True, -33]},
|
||||
{"hill": "invalid"},
|
||||
{"mode": "point"},
|
||||
{"surface": "invalid"},
|
||||
{"distance": False},
|
||||
{"mode": []},
|
||||
{"avoid_repeats": "false"},
|
||||
],
|
||||
)
|
||||
def test_validation(change):
|
||||
with pytest.raises(PlannerError):
|
||||
validate(preferences() | change)
|
||||
|
||||
|
||||
def test_point_distance_rejected():
|
||||
with pytest.raises(PlannerError, match="straight-line"):
|
||||
validate(preferences() | {"mode": "point", "end": [152, -33]})
|
||||
|
||||
|
||||
def test_quality_and_missing_data():
|
||||
plan = analyse(feature(), validate(preferences()))
|
||||
assert plan["quality"] == {
|
||||
"paths_pct": 80,
|
||||
"roads_pct": 20,
|
||||
"paved_pct": 90,
|
||||
"unknown_surface_pct": 10,
|
||||
"repeats_pct": 50,
|
||||
}
|
||||
assert plan["within_target"]
|
||||
assert len(plan["profile"]) == 3
|
||||
assert plan["profile"][-1][0] == 5100
|
||||
missing = feature()
|
||||
missing["properties"].pop("extras")
|
||||
missing["properties"].pop("ascent")
|
||||
for coordinate in missing["geometry"]["coordinates"]:
|
||||
coordinate.pop()
|
||||
plan = analyse(missing, validate(preferences()))
|
||||
assert plan["quality"]["paths_pct"] is None
|
||||
assert plan["quality"]["unknown_surface_pct"] is None
|
||||
assert plan["ascent_m"] is None
|
||||
assert plan["profile"] == []
|
||||
|
||||
|
||||
def test_provider_float_category_ids():
|
||||
item = feature()
|
||||
for extra in item["properties"]["extras"].values():
|
||||
for row in extra["summary"]:
|
||||
row["value"] = float(row["value"])
|
||||
plan = analyse(item, validate(preferences()))
|
||||
assert plan["quality"]["paths_pct"] == 80
|
||||
assert plan["quality"]["roads_pct"] == 20
|
||||
assert plan["quality"]["paved_pct"] == 90
|
||||
assert plan["quality"]["unknown_surface_pct"] == 10
|
||||
|
||||
|
||||
def test_ranking_and_provider_contract(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake(body, key):
|
||||
calls.append(deepcopy(body))
|
||||
item = feature()
|
||||
item["properties"]["summary"]["distance"] = [7000, 5050, 5300][len(calls) - 1]
|
||||
item["geometry"]["coordinates"][1][0] += len(calls) / 100
|
||||
return {"features": [item]}
|
||||
|
||||
monkeypatch.setattr("app.planner_engine.provider_request", fake)
|
||||
routes, errors = generate(validate(preferences() | {"priority": "distance"}), "test")
|
||||
assert len(routes) == 3 and not errors
|
||||
assert routes[0]["distance_m"] == 5050
|
||||
assert [x["options"]["round_trip"]["seed"] for x in calls] == [1, 2, 3]
|
||||
assert all(x["elevation"] and x["extra_info"] == ["surface", "waytype"] for x in calls)
|
||||
|
||||
|
||||
def test_point_requests_and_duplicates(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake(body, key):
|
||||
calls.append(body)
|
||||
return {"features": [feature()]}
|
||||
|
||||
monkeypatch.setattr("app.planner_engine.provider_request", fake)
|
||||
routes, _ = generate(validate(preferences() | {"mode": "point", "end": [151.01, -33]}), "test")
|
||||
assert len(routes) == 1
|
||||
assert [len(body["coordinates"]) for body in calls] == [2, 3, 3]
|
||||
assert all("options" not in body for body in calls)
|
||||
|
||||
|
||||
def test_generate_save_export_delete_and_ownership(app, monkeypatch):
|
||||
monkeypatch.setattr("app.planner_engine.provider_request", lambda *_: {"features": [feature()]})
|
||||
client = app.test_client()
|
||||
assert client.get("/routes").status_code == 302
|
||||
login(client)
|
||||
assert client.get("/routes").status_code == 200
|
||||
response = client.post("/routes/generate", json=preferences())
|
||||
assert response.status_code == 200
|
||||
route_id = response.json["routes"][0]["id"]
|
||||
assert client.get(f"/routes/{route_id}/gpx").status_code == 404
|
||||
assert (
|
||||
client.post(f"/routes/{route_id}/save", json={"title": "Morning & hills"}).status_code
|
||||
== 200
|
||||
)
|
||||
exported = client.get(f"/routes/{route_id}/gpx")
|
||||
assert exported.status_code == 200
|
||||
document = ElementTree.fromstring(exported.data)
|
||||
ns = {"g": "http://www.topografix.com/GPX/1/1"}
|
||||
assert document.find("g:trk/g:name", ns).text == "Morning & hills"
|
||||
assert len(document.findall(".//g:trkpt", ns)) == 3
|
||||
assert document.find(".//g:trkpt", ns).attrib == {"lat": "-33.0", "lon": "151.0"}
|
||||
other = app.test_client()
|
||||
login(other, "other")
|
||||
for suffix in ("", "/gpx"):
|
||||
assert other.get(f"/routes/{route_id}{suffix}").status_code == 404
|
||||
for suffix in ("save", "delete"):
|
||||
assert (
|
||||
other.post(f"/routes/{route_id}/{suffix}", json={"title": "stolen"}).status_code == 404
|
||||
)
|
||||
assert b"Morning & hills" not in other.get("/routes").data
|
||||
assert client.post(f"/routes/{route_id}/delete", json={}).status_code == 200
|
||||
assert client.get(f"/routes/{route_id}").status_code == 404
|
||||
|
||||
|
||||
def test_configuration_failure_and_csrf(app):
|
||||
app.config["ORS_API_KEY"] = None
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
assert b"not connected yet" in client.get("/routes").data
|
||||
response = client.post("/routes/generate", json=preferences())
|
||||
assert response.status_code == 400 and "ORS_API_KEY" in response.json["error"]
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
assert client.post("/routes/generate", json=preferences()).status_code == 400
|
||||
|
||||
|
||||
def test_old_candidates_cleanup_and_saved_preservation(app, monkeypatch):
|
||||
monkeypatch.setattr("app.planner_engine.provider_request", lambda *_: {"features": [feature()]})
|
||||
with app.app_context():
|
||||
for saved in (False, True):
|
||||
db.session.add(
|
||||
PlannedRoute(
|
||||
owner_id=1,
|
||||
title="old",
|
||||
saved=saved,
|
||||
created_at=datetime.now(UTC) - timedelta(days=2),
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
assert client.post("/routes/generate", json=preferences()).status_code == 200
|
||||
with app.app_context():
|
||||
assert db.session.get(PlannedRoute, 1) is None
|
||||
assert db.session.get(PlannedRoute, 2).saved
|
||||
Reference in New Issue
Block a user