Settings page mostly complete.

This commit is contained in:
Yaro Kasear 2025-10-15 13:59:58 -05:00
parent e84228161a
commit ae54277e58
9 changed files with 406 additions and 174 deletions

View file

@ -25,158 +25,141 @@ def _link_with_params(base_url: str, **params) -> str:
return f"{base_url}?{urlencode(q)}"
def generate_crud_blueprint(model, service, *, base_prefix: str | None = None):
def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, rest: bool = True, rpc: bool = True):
"""
RPC-ish blueprint that exposes CRUDService methods 1:1:
REST:
GET /api/<models>/ -> list (filters via ?q=..., sort=..., limit=..., cursor=...)
GET /api/<models>/<id> -> get
POST /api/<models>/ -> create
PATCH /api/<models>/<id> -> update (partial)
DELETE /api/<models>/<id>[?hard=1] -> delete
GET /api/<model>/get?id=123&... -> service.get()
GET /api/<model>/list?... -> service.list()
GET /api/<model>/seek_window?... -> service.seek_window()
GET /api/<model>/page?page=2&per_page=50&... -> service.page()
POST /api/<model>/create -> service.create(payload)
PATCH /api/<model>/update?id=123 -> service.update(id, payload)
DELETE /api/<model>/delete?id=123[&hard=1] -> service.delete(id, hard)
Query params for filters/sorts/fields/includes all still pass straight through.
Cursor behavior for seek_window is preserved, with Link headers.
RPC (legacy):
GET /api/<model>/get?id=123
GET /api/<model>/list
GET /api/<model>/seek_window
GET /api/<model>/page
POST /api/<model>/create
PATCH /api/<model>/update?id=123
DELETE /api/<model>/delete?id=123[&hard=1]
"""
name = (model.__name__ if base_prefix is None else base_prefix).lower()
bp = Blueprint(name, __name__, url_prefix=f"/api/{name}")
model_name = model.__name__.lower()
# bikeshed if you want pluralization; this is the least-annoying default
collection = (base_prefix or model_name).lower()
plural = collection if collection.endswith('s') else f"{collection}s"
# -------- READS --------
bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}")
@bp.get("/get")
def rpc_get():
id_ = _safe_int(request.args.get("id"), 0)
if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400
try:
item = service.get(id_, request.args)
if item is None:
abort(404)
return jsonify(item.as_dict())
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.get("/list")
def rpc_list():
# Keep legacy limit/offset behavior. Everything else passes through.
args = request.args.to_dict(flat=True)
# If the caller provides offset or page, honor normal list() pagination rules.
legacy_offset = ("offset" in args) or ("page" in args)
if not legacy_offset:
# We still allow limit to cap the result set if provided.
limit = _safe_int(args.get("limit"), 50)
args["limit"] = limit
try:
items = service.list(args)
return jsonify([obj.as_dict() for obj in items])
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.get("/seek_window")
def rpc_seek_window():
args = request.args.to_dict(flat=True)
# Keep keyset & cursor mechanics intact
cursor_token = args.get("cursor")
key, desc_from_cursor, backward_from_cursor = decode_cursor(cursor_token)
backward = _bool_param(args, "backward", backward_from_cursor if backward_from_cursor is not None else False)
include_total = _bool_param(args, "include_total", True)
try:
window = service.seek_window(
args,
key=key,
backward=backward,
include_total=include_total,
)
# ---------- REST ----------
if rest:
@bp.get("/")
def rest_list():
args = request.args.to_dict(flat=True)
# support cursor pagination transparently; fall back to limit/offset
try:
desc_flags = list(window.order.desc)
except Exception:
desc_flags = desc_from_cursor or []
items = service.list(args)
return jsonify([o.as_dict() for o in items])
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
body = {
"items": [obj.as_dict() for obj in window.items],
"limit": window.limit,
"next_cursor": encode_cursor(window.last_key, desc_flags, backward=False),
"prev_cursor": encode_cursor(window.first_key, desc_flags, backward=True),
"total": window.total,
}
resp = jsonify(body)
@bp.get("/<int:obj_id>")
def rest_get(obj_id: int):
try:
item = service.get(obj_id, request.args)
if item is None:
abort(404)
return jsonify(item.as_dict())
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
# Build Link headers preserving all non-cursor args
base_url = request.base_url
base_params = {k: v for k, v in args.items() if k not in {"cursor"}}
link_parts = []
if body["next_cursor"]:
link_parts.append(f'<{_link_with_params(base_url, **base_params, cursor=body["next_cursor"])}>; rel="next"')
if body["prev_cursor"]:
link_parts.append(f'<{_link_with_params(base_url, **base_params, cursor=body["prev_cursor"])}>; rel="prev"')
if link_parts:
resp.headers["Link"] = ", ".join(link_parts)
return resp
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.post("/")
def rest_create():
payload = request.get_json(silent=True) or {}
try:
obj = service.create(payload)
resp = jsonify(obj.as_dict())
resp.status_code = 201
resp.headers["Location"] = f"{request.base_url.rstrip('/')}/{obj.id}"
return resp
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.get("/page")
def rpc_page():
args = request.args.to_dict(flat=True)
page = _safe_int(args.get("page"), 1)
per_page = _safe_int(args.get("per_page"), 50)
include_total = _bool_param(args, "include_total", True)
@bp.patch("/<int:obj_id>")
def rest_update(obj_id: int):
payload = request.get_json(silent=True) or {}
try:
obj = service.update(obj_id, payload)
return jsonify(obj.as_dict())
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
try:
result = service.page(args, page=page, per_page=per_page, include_total=include_total)
# Already includes: items, page, per_page, total, pages, order
# Items come back as model instances; serialize to dicts
result = {
**result,
"items": [obj.as_dict() for obj in result["items"]],
}
return jsonify(result)
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.delete("/<int:obj_id>")
def rest_delete(obj_id: int):
hard = (request.args.get("hard") in ("1", "true", "yes"))
try:
obj = service.delete(obj_id, hard=hard)
if obj is None:
abort(404)
return ("", 204)
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
# -------- WRITES --------
# ---------- RPC (your existing routes) ----------
if rpc:
# your original functions verbatim, shortened here for sanity
@bp.get("/get")
def rpc_get():
id_ = int(request.args.get("id", 0))
if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400
try:
item = service.get(id_, request.args)
if item is None:
abort(404)
return jsonify(item.as_dict())
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.post("/create")
def rpc_create():
payload = request.get_json(silent=True) or {}
try:
obj = service.create(payload)
return jsonify(obj.as_dict()), 201
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.get("/list")
def rpc_list():
args = request.args.to_dict(flat=True)
try:
items = service.list(args)
return jsonify([obj.as_dict() for obj in items])
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.patch("/update")
def rpc_update():
id_ = _safe_int(request.args.get("id"), 0)
if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400
payload = request.get_json(silent=True) or {}
try:
obj = service.update(id_, payload)
return jsonify(obj.as_dict())
except Exception as e:
# If you ever decide to throw custom exceptions, map them here like an adult.
return jsonify({"status": "error", "error": str(e)}), 400
@bp.post("/create")
def rpc_create():
payload = request.get_json(silent=True) or {}
try:
obj = service.create(payload)
return jsonify(obj.as_dict()), 201
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.delete("/delete")
def rpc_delete():
id_ = _safe_int(request.args.get("id"), 0)
if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400
hard = _bool_param(request.args, "hard", False)
try:
obj = service.delete(id_, hard=hard)
# 204 if actually deleted or soft-deleted; return body if you feel chatty
return ("", 204) if obj is not None else abort(404)
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.patch("/update")
def rpc_update():
id_ = int(request.args.get("id", 0))
if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400
payload = request.get_json(silent=True) or {}
try:
obj = service.update(id_, payload)
return jsonify(obj.as_dict())
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@bp.delete("/delete")
def rpc_delete():
id_ = int(request.args.get("id", 0))
if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400
hard = (request.args.get("hard") in ("1", "true", "yes"))
try:
obj = service.delete(id_, hard=hard)
return ("", 204) if obj is not None else abort(404)
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
return bp