Files
Stride/app/routes.py
T

639 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import csv
import hashlib
import io
import re
import secrets
from datetime import UTC, datetime
from pathlib import Path
from zoneinfo import available_timezones
from flask import (
Blueprint,
Response,
abort,
current_app,
flash,
jsonify,
redirect,
render_template,
request,
session,
url_for,
)
from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy.exc import IntegrityError
from werkzeug.security import check_password_hash, generate_password_hash
from garmin_sync.service import SyncError, atomic_write
from .analytics import pace_text
from .display import stored_analysis
from .extensions import db, limiter
from .history import activity_page, latest_mapped_run, overview, records_for
from .models import Activity, Friendship, GarminConnection, SavedAnalysis, User
from .services import activity_data, import_cache, import_upload, route_data
from .stats import DEFAULT, METRICS, explore, parse_query, period_bounds, query_text
from .stats_analysis import EXTRA_UNITS, FUNCTIONS, GROUPS
from .stats_dsl import UNITS
from .stats_expressions import FUNCTIONS as METRIC_FUNCTIONS
web = Blueprint("web", __name__)
DUMMY_HASH = generate_password_hash("not-a-real-account-password")
def setup_available():
return (
current_app.config["LOCAL_SETUP"]
and request.remote_addr in {"127.0.0.1", "::1"}
and db.session.scalar(db.select(db.func.count(User.id))) == 0
)
def bootstrap_directory():
directories = [
p
for p in Path(current_app.config["DATA_ROOT"]).glob("*")
if p.is_dir() and re.fullmatch(r"[a-f0-9]{24}", p.name)
]
return directories[0] if len(directories) == 1 else None
def get_activity(activity_id, require_owner=False):
row = db.get_or_404(Activity, activity_id)
owner = current_user.is_authenticated and row.owner_id == current_user.id
allowed = (
current_user.is_authenticated
and row.visibility == "friends"
and are_friends(current_user.id, row.owner_id)
)
if (require_owner and not owner) or (not owner and not allowed):
abort(404)
return row, owner
def are_friends(first, second):
low, high = sorted((first, second))
return (
db.session.scalar(
db.select(Friendship.id).filter_by(low_id=low, high_id=high, status="accepted")
)
is not None
)
@web.get("/health")
def health():
db.session.execute(db.text("SELECT 1"))
return {"status": "ok"}
@web.get("/")
def index():
if setup_available():
return redirect(url_for("web.setup"))
return redirect(url_for("web.dashboard" if current_user.is_authenticated else "web.login"))
def register_user(is_setup=False):
username = secrets.token_hex(10)
email = request.form.get("email", "").strip().lower()
display_name = request.form.get("display_name", "").strip()
password = request.form.get("password", "")
if not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) or len(email) > 254:
return "Enter a valid email address."
if not 1 <= len(display_name) <= 80:
return "Enter a display name of up to 80 characters."
if not 12 <= len(password) <= 128:
return "Use a password between 12 and 128 characters."
user = User(username=username, email=email, display_name=display_name)
user.set_password(password)
directory = bootstrap_directory() if is_setup else None
if directory:
user.local_account_key = directory.name
try:
db.session.add(user)
db.session.flush()
count = import_cache(user, current_app.config["DATA_ROOT"]) if directory else 0
db.session.commit()
except IntegrityError:
db.session.rollback()
return "That email is already registered."
session.clear()
login_user(user)
flash(
f"Welcome to Stride. {count} activities imported privately."
if count
else "Welcome to Stride."
)
return None
@web.route("/setup", methods=["GET", "POST"])
@limiter.limit("10 per hour", methods=["POST"])
def setup():
if not setup_available():
abort(404)
directory = bootstrap_directory()
count = len(list(directory.glob("*/manifest.json"))) if directory else 0
error = register_user(True) if request.method == "POST" else None
if request.method == "POST" and not error:
return redirect(url_for("web.dashboard"))
return render_template("auth.html", mode="setup", error=error, import_count=count)
@web.route("/register", methods=["GET", "POST"])
@limiter.limit("10 per hour", methods=["POST"])
def register():
if setup_available():
return redirect(url_for("web.setup"))
if current_user.is_authenticated:
return redirect(url_for("web.dashboard"))
error = register_user() if request.method == "POST" else None
if request.method == "POST" and not error:
return redirect(url_for("web.dashboard"))
return render_template("auth.html", mode="register", error=error)
@web.route("/login", methods=["GET", "POST"])
@limiter.limit("5 per minute", methods=["POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("web.dashboard"))
error = None
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
user = db.session.scalar(db.select(User).filter_by(email=email))
valid = check_password_hash(user.password_hash if user else DUMMY_HASH, password[:129])
if user and valid and len(password) <= 128:
session.clear()
login_user(user)
return redirect(url_for("web.dashboard"))
error = "Email or password was not recognised."
return render_template("auth.html", mode="login", error=error)
@web.post("/logout")
@login_required
def logout():
logout_user()
session.clear()
return redirect(url_for("web.login"))
@web.get("/dashboard")
@login_required
def dashboard():
filters = [Activity.owner_id == current_user.id]
stats = overview(filters, current_user.timezone)
pagination = activity_page(filters, total=stats["count"])
return render_template(
"dashboard.html",
rows=pagination.items,
pagination=pagination,
stats=stats,
profile=current_user,
personal=True,
record_book=records_for(current_user.id, {row.id for row in pagination.items}),
mapped_run=latest_mapped_run(filters),
pace_text=pace_text,
analyses=db.session.scalars(
db.select(SavedAnalysis)
.filter_by(owner_id=current_user.id, pinned=True)
.order_by(SavedAnalysis.position, SavedAnalysis.id)
).all(),
)
@web.get("/stats")
@login_required
def stats():
from .analysis_library import owned, serialize
from .stats_presets import PRESETS
saved = None
if request.args.get("analysis"):
try:
analysis_id = int(request.args["analysis"])
except ValueError:
abort(404)
saved = owned(analysis_id)
args = dict(request.args)
if saved and "query" not in args:
args["query"] = saved.query
error = None
results = None
try:
query = parse_query(args)
page = int(request.args.get("page", "1"))
results = explore(current_user.id, current_user.timezone, query, page)
except (ValueError, OverflowError) as exc:
error = str(exc)
query = dict(DEFAULT)
return render_template(
"stats.html",
query=query,
query_text=query_text(query),
metrics=METRICS,
results=results,
error=error,
submitted_query=args.get("query", ""),
selected_analysis=serialize(saved) if saved else None,
presets=PRESETS,
language={
"functions": sorted(FUNCTIONS),
"metric_functions": METRIC_FUNCTIONS,
"groups": sorted(GROUPS),
"fields": {
key: {
"label": info[0],
"unit": info[1],
"description": info[2],
"units": list(({**UNITS, **EXTRA_UNITS}).get(key, {})),
}
for key, info in METRICS.items()
},
},
), 400 if error else 200
@web.get("/stats/runs")
@login_required
def stats_runs():
try:
query = parse_query(request.args)
bucket = request.args.get("bucket", "")
results = explore(
current_user.id,
current_user.timezone,
query,
int(request.args.get("page", "1")),
selected_bucket=bucket,
raw_group=request.args.get("scope") == "group",
)
except (ValueError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
return jsonify(
rows=results["rows"],
count=results["evidence_count"],
page=results["page"],
pages=results["pages"],
)
@web.post("/stats/validate")
@login_required
@limiter.limit("120 per minute")
def stats_validate():
payload = request.get_json(silent=True)
if not isinstance(payload, dict) or not isinstance(payload.get("query"), str):
return jsonify(valid=False, diagnostics=[{"line": 1, "message": "Supply query text."}]), 400
try:
query = parse_query({"query": payload["query"]})
period_bounds(query, current_user.timezone)
return jsonify(
valid=True, canonical=query_text(query), diagnostics=[], derived=query["derived"]
)
except (ValueError, OverflowError) as exc:
return jsonify(
valid=False, diagnostics=[{"line": getattr(exc, "line", 1), "message": str(exc)}]
)
@web.get("/stats/export")
@login_required
@limiter.limit("20 per minute")
def stats_export():
try:
query = parse_query(request.args)
result = explore(current_user.id, current_user.timezone, query, export=True)
kind = request.args.get("kind", "groups")
if kind not in {"runs", "groups"}:
raise ValueError("Export runs or groups.")
except (ValueError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
output = io.StringIO(newline="")
writer = csv.writer(output)
def safe(value):
# Keep user-authored run titles inert when opened in a spreadsheet.
text = str(value)
return "'" + text if text.lstrip().startswith(("=", "+", "-", "@")) else text
columns = result["cards"] if kind == "groups" else result["evidence_columns"]
headers = [
f"{card['key']} ({'sec/km' if card['unit'] == '/km' else card['unit']})" for card in columns
]
if kind == "groups":
writer.writerow(["Group", "Contributing runs", *headers])
for group in result["groups"]:
writer.writerow([safe(group["label"]), group["count"], *group["raw_values"]])
else:
writer.writerow(
["Run", "Local date", "Distance (km)", "Previous run", "Previous run date", *headers]
)
for row in result["rows"]:
previous = row["previous_run"] or {}
writer.writerow(
[
safe(row["title"]),
row["date"],
row["distance"],
safe(previous.get("title", "")),
previous.get("date", ""),
*row["raw_values"],
]
)
return Response(
output.getvalue(),
mimetype="text/csv",
headers={
"Content-Disposition": f'attachment; filename="stride-{kind}.csv"',
"Cache-Control": "private, no-store",
},
)
@web.route("/friends", methods=["GET", "POST"])
@login_required
@limiter.limit("20 per hour", methods=["POST"])
def friends():
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
other = db.session.scalar(db.select(User).filter_by(email=email))
if other and other.id != current_user.id:
low, high = sorted((current_user.id, other.id))
existing = db.session.scalar(db.select(Friendship).filter_by(low_id=low, high_id=high))
if not existing:
db.session.add(Friendship(low_id=low, high_id=high, requested_by=current_user.id))
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
flash(
"If that person has a Stride account, your request is available in their Friends page."
)
return redirect(url_for("web.friends"))
links = db.session.scalars(
db.select(Friendship).where(
db.or_(Friendship.low_id == current_user.id, Friendship.high_id == current_user.id)
)
).all()
people = [
{
"link": link,
"person": db.session.get(
User, link.high_id if link.low_id == current_user.id else link.low_id
),
}
for link in links
]
accepted_ids = [p["person"].id for p in people if p["link"].status == "accepted"]
pagination = activity_page(
[Activity.owner_id.in_(accepted_ids), Activity.visibility == "friends"]
)
return render_template(
"friends.html", people=people, rows=pagination.items, pagination=pagination
)
@web.post("/friends/<int:friendship_id>/<action>")
@login_required
def friendship_action(friendship_id, action):
link = db.get_or_404(Friendship, friendship_id)
if current_user.id not in (link.low_id, link.high_id):
abort(404)
if action == "accept" and link.status == "pending" and link.requested_by != current_user.id:
link.status = "accepted"
flash("You’re now friends. Activities shared with friends are visible to both of you.")
elif action == "remove":
db.session.delete(link)
flash("Connection removed. Shared activities are no longer accessible between you.")
else:
abort(400)
db.session.commit()
return redirect(url_for("web.friends"))
@web.get("/u/<username>")
@login_required
def profile(username):
user = db.first_or_404(db.select(User).filter_by(username=username))
if user.id != current_user.id and not are_friends(current_user.id, user.id):
abort(404)
if user.id == current_user.id:
return redirect(url_for("web.dashboard"))
filters = [Activity.owner_id == user.id, Activity.visibility == "friends"]
stats = overview(filters, user.timezone)
pagination = activity_page(filters, total=stats["count"])
return render_template(
"dashboard.html",
rows=pagination.items,
pagination=pagination,
stats=stats,
profile=user,
personal=False,
mapped_run=latest_mapped_run(filters),
record_book=records_for(user.id, {row.id for row in pagination.items}, shared_only=True),
pace_text=pace_text,
)
@web.get("/activities/<int:activity_id>")
def activity(activity_id):
row, owner = get_activity(activity_id)
analysis = stored_analysis(row)
efforts = []
if owner:
efforts = records_for(row.owner_id, {row.id})["runs"].get(row.id, [])
return render_template(
"activity.html",
activity=row,
owner_view=owner,
analysis=analysis,
pace_text=pace_text,
efforts=efforts,
)
@web.get("/activities/<int:activity_id>/data")
def data(activity_id):
row, owner = get_activity(activity_id)
return jsonify(activity_data(row, owner))
@web.get("/activities/<int:activity_id>/route")
def route(activity_id):
row, _ = get_activity(activity_id)
return jsonify(route_data(row))
@web.post("/activities/<int:activity_id>/share")
@login_required
def share_activity(activity_id):
row, _ = get_activity(activity_id, True)
if not row.share_token:
# Conditional update keeps simultaneous copy requests on the same link.
db.session.execute(
db.update(Activity)
.where(Activity.id == row.id, Activity.share_token.is_(None))
.values(share_token=secrets.token_urlsafe(32))
)
db.session.commit()
db.session.refresh(row)
return jsonify(url=url_for("web.shared_activity", token=row.share_token, _external=True))
@web.post("/activities/<int:activity_id>/share/revoke")
@login_required
def revoke_activity_share(activity_id):
row, _ = get_activity(activity_id, True)
row.share_token = None
db.session.commit()
flash("Public link disabled. Previously copied links no longer work.")
return redirect(url_for("web.activity", activity_id=row.id))
@web.get("/shared/runs/<token>")
def shared_activity(token):
row = db.first_or_404(db.select(Activity).filter_by(share_token=token))
return render_template(
"activity.html",
activity=row,
owner_view=False,
public_view=True,
analysis=stored_analysis(row),
pace_text=pace_text,
data_url=url_for("web.shared_activity_data", token=token),
)
@web.get("/shared/runs/<token>/data")
def shared_activity_data(token):
row = db.first_or_404(db.select(Activity).filter_by(share_token=token))
return jsonify(activity_data(row, False))
@web.post("/activities/<int:activity_id>/edit")
@login_required
def edit_activity(activity_id):
row, _ = get_activity(activity_id, True)
title = request.form.get("title", "").strip()
visibility = request.form.get("visibility", "private")
if not title or len(title) > 160 or visibility not in {"friends", "private"}:
abort(400)
row.title, row.visibility = title, visibility
db.session.commit()
flash("Activity updated.")
return redirect(url_for("web.activity", activity_id=row.id))
@web.route("/settings", methods=["GET", "POST"])
@login_required
def settings():
if request.method == "POST":
name = request.form.get("display_name", "").strip()
timezone = request.form.get("timezone", "")
if not 1 <= len(name) <= 80 or timezone not in available_timezones():
abort(400)
current_user.display_name, current_user.timezone = name, timezone
db.session.commit()
flash("Profile saved.")
return redirect(url_for("web.settings"))
connection = db.session.scalar(db.select(GarminConnection).filter_by(user_id=current_user.id))
return render_template("settings.html", connection=connection)
@web.post("/garmin/<action>")
@login_required
@limiter.limit("10 per hour")
def garmin_action(action):
from filelock import Timeout
from .sync import connect, session_lock
if action not in {"connect", "sync", "schedule", "disconnect", "health"}:
abort(404)
try:
if action == "connect":
file = request.files.get("tokens")
if not file:
raise SyncError("Choose your exported Garmin session JSON file.")
connect(current_user, file.read(65537))
flash("Garmin connected. Your first sync is queued.")
else:
with session_lock(current_user.id):
connection = db.session.scalar(
db.select(GarminConnection).filter_by(user_id=current_user.id)
)
if not connection:
raise SyncError("Connect Garmin first.")
if action == "sync":
connection.pending = True
connection.message = "Manual sync queued."
elif action == "schedule":
connection.scheduled = request.form.get("enabled") == "yes"
connection.next_sync = datetime.now(UTC)
elif action == "health":
connection.health_enabled = request.form.get("enabled") == "yes"
connection.health_message = "Daily recovery sync queued." if connection.health_enabled else "Daily recovery sync is off."
if connection.health_enabled:
connection.pending = True
else:
db.session.delete(connection)
current_user.local_account_key = None
db.session.commit()
flash("Garmin sync settings updated.")
except Timeout:
db.session.rollback()
flash("A sync is running. Please retry shortly.", "error")
except SyncError as error:
db.session.rollback()
flash(str(error), "error")
except Exception: # noqa: BLE001 -- suppress provider credentials at boundary
db.session.rollback()
flash("Garmin connection failed. Check your session file and try again.", "error")
return redirect(url_for("web.settings"))
@web.post("/imports/cache")
@login_required
def import_local():
if not current_user.local_account_key:
abort(404)
try:
count = import_cache(current_user, current_app.config["DATA_ROOT"])
db.session.commit()
flash(f"Imported {count} new activities from your local Garmin sync.")
except (SyncError, ValueError, OSError):
db.session.rollback()
flash("An import could not be read. Check the local sync output and try again.", "error")
return redirect(url_for("web.dashboard"))
@web.post("/imports/fit")
@login_required
def upload():
file = request.files.get("file")
if not file or not file.filename.lower().endswith(".fit"):
flash("Choose a FIT activity file.", "error")
return redirect(url_for("web.settings"))
payload = file.read(16 * 1024 * 1024 + 1)
if len(payload) > 16 * 1024 * 1024:
abort(413)
try:
row = import_upload(current_user, payload)
if row:
destination = Path(current_app.instance_path) / "uploads" / str(current_user.id)
atomic_write(destination / f"{hashlib.sha256(payload).hexdigest()}.fit", payload)
db.session.commit()
flash("Activity imported privately.")
return redirect(url_for("web.activity", activity_id=row.id))
flash("You have already imported that file.")
except (SyncError, ValueError, TypeError, OSError):
db.session.rollback()
flash(
"The FIT file could not be imported. Check it contains a completed activity.", "error"
)
return redirect(url_for("web.settings"))