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("/") @login_required def detail(route_id): return jsonify(serialise(owned(route_id, saved_only=True))) @planner.post("//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("//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("//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"'}, )