Add saved analysis workspace and live dashboard statistics
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""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"},
|
||||
)
|
||||
Reference in New Issue
Block a user