166 lines
5.2 KiB
Python
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"},
|
|
)
|