Add running route planner with quality comparison and GPX export
This commit is contained in:
+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"'},
|
||||
)
|
||||
Reference in New Issue
Block a user