Files
Stride/app/analysis_library.py

166 lines
5.2 KiB
Python

"""Private account library. Queries execute afresh against owner-scoped history."""
from flask import Blueprint, abort, jsonify, request
from flask_login import current_user, login_required
from .extensions import db, limiter
from .models import SavedAnalysis
from .stats import explore, parse_query, period_bounds, query_text
library = Blueprint("library", __name__, url_prefix="/stats/analyses")
def serialize(row):
return {
key: getattr(row, key)
for key in (
"id",
"name",
"collection",
"notes",
"query",
"pinned",
"position",
"width",
"view",
)
} | {"updated_at": row.updated_at.isoformat()}
def owned(analysis_id):
row = db.session.scalar(
db.select(SavedAnalysis).filter_by(id=analysis_id, owner_id=current_user.id)
)
if row is None:
abort(404)
return row
def validate(payload, row=None):
if not isinstance(payload, dict):
raise TypeError("Supply analysis settings.")
values = {}
for key, limit in (("name", 100), ("collection", 80), ("notes", 4000)):
value = payload.get(
key,
getattr(row, key)
if row
else {"name": "", "collection": "My analyses", "notes": ""}[key],
)
if (
not isinstance(value, str)
or len(value.strip()) > limit
or (key != "notes" and not value.strip())
):
raise ValueError(f"Supply {key} of up to {limit} characters.")
values[key] = value.strip()
source = payload.get("query", row.query if row else None)
query = parse_query({"query": source})
period_bounds(query, current_user.timezone)
values["query"] = query_text(query)
for key, choices, default in (
("width", {"half", "full"}, "half"),
("view", {"chart", "value", "table"}, "chart"),
):
value = payload.get(key, getattr(row, key) if row else default)
if not isinstance(value, str) or value not in choices:
raise ValueError(f"Choose a supported {key}.")
values[key] = value
pinned = payload.get("pinned", row.pinned if row else False)
if not isinstance(pinned, bool):
raise TypeError("Pinned must be true or false.")
values["pinned"] = pinned
return values
@library.get("")
@login_required
def listing():
rows = db.session.scalars(
db.select(SavedAnalysis)
.filter_by(owner_id=current_user.id)
.order_by(SavedAnalysis.position, SavedAnalysis.id)
).all()
return jsonify(analyses=[serialize(row) for row in rows])
@library.post("")
@login_required
@limiter.limit("60 per minute")
def create():
try:
values = validate(request.get_json(silent=True))
except (ValueError, TypeError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
count = db.session.scalar(
db.select(db.func.count(SavedAnalysis.id)).filter_by(owner_id=current_user.id)
)
if count >= 100:
return jsonify(error="Keep up to 100 saved analyses; delete an unused analysis first."), 400
row = SavedAnalysis(owner_id=current_user.id, position=count, **values)
db.session.add(row)
db.session.commit()
return jsonify(analysis=serialize(row)), 201
@library.patch("/<int:analysis_id>")
@login_required
def update(analysis_id):
row = owned(analysis_id)
try:
values = validate(request.get_json(silent=True), row)
except (ValueError, TypeError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
for key, value in values.items():
setattr(row, key, value)
db.session.commit()
return jsonify(analysis=serialize(row))
@library.delete("/<int:analysis_id>")
@login_required
def delete(analysis_id):
db.session.delete(owned(analysis_id))
db.session.commit()
return "", 204
@library.post("/order")
@login_required
def order():
payload = request.get_json(silent=True)
ids = payload.get("ids") if isinstance(payload, dict) else None
rows = db.session.scalars(
db.select(SavedAnalysis).filter_by(owner_id=current_user.id, pinned=True)
).all()
if (
not isinstance(ids, list)
or any(type(item) is not int for item in ids)
or len(ids) != len(set(ids))
or set(ids) != {row.id for row in rows}
):
return jsonify(error="Supply every pinned analysis exactly once."), 400
positions = {analysis_id: position for position, analysis_id in enumerate(ids)}
for row in rows:
row.position = positions[row.id]
db.session.commit()
return jsonify(ok=True)
@library.get("/<int:analysis_id>/results")
@login_required
@limiter.limit("120 per minute")
def results(analysis_id):
row = owned(analysis_id)
try:
result = explore(current_user.id, current_user.timezone, parse_query({"query": row.query}))
except (ValueError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
for key in ("start", "end", "previous_start", "previous_end"):
result[key] = result[key].isoformat()
return (
jsonify(analysis=serialize(row), results=result),
200,
{"Cache-Control": "private, no-store"},
)