Extend run stats DSL with statistical and temporal analysis
This commit is contained in:
+77
-4
@@ -1,4 +1,6 @@
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import re
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
@@ -7,6 +9,7 @@ from zoneinfo import available_timezones
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
current_app,
|
||||
flash,
|
||||
@@ -30,6 +33,8 @@ from .history import activity_page, latest_mapped_run, overview, records_for
|
||||
from .models import Activity, Friendship, GarminConnection, 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
|
||||
|
||||
web = Blueprint("web", __name__)
|
||||
DUMMY_HASH = generate_password_hash("not-a-real-account-password")
|
||||
@@ -214,6 +219,19 @@ def stats():
|
||||
results=results,
|
||||
error=error,
|
||||
submitted_query=request.args.get("query", ""),
|
||||
language={
|
||||
"functions": sorted(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
|
||||
|
||||
|
||||
@@ -222,15 +240,14 @@ def stats():
|
||||
def stats_runs():
|
||||
try:
|
||||
query = parse_query(request.args)
|
||||
bucket = (
|
||||
datetime.strptime(request.args.get("bucket", ""), "%d %b %Y").replace(tzinfo=UTC).date()
|
||||
)
|
||||
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
|
||||
@@ -252,13 +269,69 @@ def stats_validate():
|
||||
try:
|
||||
query = parse_query({"query": payload["query"]})
|
||||
period_bounds(query, current_user.timezone)
|
||||
return jsonify(valid=True, canonical=query_text(query), diagnostics=[])
|
||||
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"])
|
||||
|
||||
Reference in New Issue
Block a user