"""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("/") @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("/") @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("//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"}, )