Add running route planner with quality comparison and GPX export

This commit is contained in:
Peter Stockings
2026-10-03 00:33:41 +10:00
parent a06189849e
commit 52943cd05b
12 changed files with 899 additions and 0 deletions
+111
View File
@@ -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"'},
)