Downstream changes.

This commit is contained in:
Yaro Kasear 2025-10-22 12:44:00 -05:00
parent f956e09e2b
commit ec82ca2394
4 changed files with 261 additions and 181 deletions

View file

@ -2,55 +2,140 @@
from __future__ import annotations from __future__ import annotations
from flask import Blueprint, jsonify, request, abort from flask import Blueprint, jsonify, request, abort, current_app, url_for
from hashlib import md5
from urllib.parse import urlencode from urllib.parse import urlencode
from werkzeug.exceptions import HTTPException
from crudkit.api._cursor import encode_cursor, decode_cursor
from crudkit.core.service import _is_truthy from crudkit.core.service import _is_truthy
MAX_JSON = 1_000_000
def _etag_for(obj) -> str:
v = getattr(obj, "updated_at", None) or obj.id
return md5(str(v).encode()).hexdigest()
def _json_payload() -> dict:
if request.content_length and request.content_length > MAX_JSON:
abort(413)
if not request.is_json:
abort(415)
payload = request.get_json(silent=False)
if not isinstance(payload, dict):
abort(400)
return payload
def _args_flat() -> dict[str, str]:
return request.args.to_dict(flat=True) # type: ignore[arg-type]
def _json_error(e: Exception, status: int = 400):
if isinstance(e, HTTPException):
status = e.code or status
msg = e.description
else:
msg = str(e)
if current_app.debug:
return jsonify({"status": "error", "error": msg, "type": e.__class__.__name__}), status
return jsonify({"status": "error", "error": msg}), status
def _bool_param(d: dict[str, str], key: str, default: bool) -> bool: def _bool_param(d: dict[str, str], key: str, default: bool) -> bool:
return _is_truthy(d.get(key, "1" if default else "0")) return _is_truthy(d.get(key, "1" if default else "0"))
def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, rest: bool = True, rpc: bool = True):
"""
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
def _safe_int(value: str | None, default: int) -> int: 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]
"""
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"
bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}")
@bp.errorhandler(Exception)
def _handle_any(e: Exception):
return _json_error(e)
@bp.errorhandler(404)
def _not_found(_e):
return jsonify({"status": "error", "error": "not found"}), 404
# ---------- REST ----------
if rest:
@bp.get("/")
def rest_list():
args = _args_flat()
# support cursor pagination transparently; fall back to limit/offset
try: try:
return int(value) if value is not None else default items = service.list(args)
except Exception: return jsonify([o.as_dict() for o in items])
return default except Exception as e:
return _json_error(e)
@bp.get("/<int:obj_id>")
def rest_get(obj_id: int):
item = service.get(obj_id, request.args)
if item is None:
abort(404)
etag = _etag_for(item)
if request.if_none_match and (etag in request.if_none_match):
return "", 304
resp = jsonify(item.as_dict())
resp.set_etag(etag)
return resp
def _link_with_params(base_url: str, **params) -> str: @bp.post("/")
q = {k: v for k, v in params.items() if v is not None} def rest_create():
return f"{base_url}?{urlencode(q)}" payload = _json_payload()
try:
obj = service.create(payload)
resp = jsonify(obj.as_dict())
resp.status_code = 201
resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False)
return resp
except Exception as e:
return _json_error(e)
@bp.patch("/<int:obj_id>")
def rest_update(obj_id: int):
payload = _json_payload()
try:
obj = service.update(obj_id, payload)
return jsonify(obj.as_dict())
except Exception as e:
return _json_error(e)
def generate_crud_blueprint(model, service, *, base_prefix: str | None = None): @bp.delete("/<int:obj_id>")
""" def rest_delete(obj_id: int):
RPC-ish blueprint that exposes CRUDService methods 1:1: hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type]
try:
GET /api/<model>/get?id=123&... -> service.get() obj = service.delete(obj_id, hard=hard)
GET /api/<model>/list?... -> service.list() if obj is None:
GET /api/<model>/seek_window?... -> service.seek_window() abort(404)
GET /api/<model>/page?page=2&per_page=50&... -> service.page() return ("", 204)
except Exception as e:
POST /api/<model>/create -> service.create(payload) return _json_error(e)
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.
"""
name = (model.__name__ if base_prefix is None else base_prefix).lower()
bp = Blueprint(name, __name__, url_prefix=f"/api/{name}")
# -------- READS --------
# ---------- RPC (your existing routes) ----------
if rpc:
@bp.get("/get") @bp.get("/get")
def rpc_get(): def rpc_get():
id_ = _safe_int(request.args.get("id"), 0) print("⚠️ WARNING: Deprecated RPC call used: /get")
id_ = int(request.args.get("id", 0))
if not id_: if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400 return jsonify({"status": "error", "error": "missing required param: id"}), 400
try: try:
@ -59,124 +144,52 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None):
abort(404) abort(404)
return jsonify(item.as_dict()) return jsonify(item.as_dict())
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
@bp.get("/list") @bp.get("/list")
def rpc_list(): def rpc_list():
# Keep legacy limit/offset behavior. Everything else passes through. print("⚠️ WARNING: Deprecated RPC call used: /list")
args = request.args.to_dict(flat=True) args = _args_flat()
# 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: try:
items = service.list(args) items = service.list(args)
return jsonify([obj.as_dict() for obj in items]) return jsonify([obj.as_dict() for obj in items])
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
@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,
)
try:
desc_flags = list(window.order.desc)
except Exception:
desc_flags = desc_from_cursor or []
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)
# 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.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)
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
# -------- WRITES --------
@bp.post("/create") @bp.post("/create")
def rpc_create(): def rpc_create():
payload = request.get_json(silent=True) or {} print("⚠️ WARNING: Deprecated RPC call used: /create")
payload = _json_payload()
try: try:
obj = service.create(payload) obj = service.create(payload)
return jsonify(obj.as_dict()), 201 return jsonify(obj.as_dict()), 201
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
@bp.patch("/update") @bp.patch("/update")
def rpc_update(): def rpc_update():
id_ = _safe_int(request.args.get("id"), 0) print("⚠️ WARNING: Deprecated RPC call used: /update")
id_ = int(request.args.get("id", 0))
if not id_: if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400 return jsonify({"status": "error", "error": "missing required param: id"}), 400
payload = request.get_json(silent=True) or {} payload = _json_payload()
try: try:
obj = service.update(id_, payload) obj = service.update(id_, payload)
return jsonify(obj.as_dict()) return jsonify(obj.as_dict())
except Exception as e: except Exception as e:
# If you ever decide to throw custom exceptions, map them here like an adult. return _json_error(e)
return jsonify({"status": "error", "error": str(e)}), 400
@bp.delete("/delete") @bp.delete("/delete")
def rpc_delete(): def rpc_delete():
id_ = _safe_int(request.args.get("id"), 0) print("⚠️ WARNING: Deprecated RPC call used: /delete")
id_ = int(request.args.get("id", 0))
if not id_: if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400 return jsonify({"status": "error", "error": "missing required param: id"}), 400
hard = _bool_param(request.args, "hard", False) hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type]
try: try:
obj = service.delete(id_, hard=hard) 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) return ("", 204) if obj is not None else abort(404)
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
return bp return bp

View file

@ -1,18 +1,66 @@
from typing import Any, Dict, Iterable, List, Tuple, Set from functools import lru_cache
from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, cast
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty, Mapper
from sqlalchemy.orm.state import InstanceState
Base = declarative_base() Base = declarative_base()
def _safe_get_loaded_attr(obj, name): @lru_cache(maxsize=512)
def _column_names_for_model(cls: type) -> tuple[str, ...]:
try:
mapper = inspect(cls)
return tuple(prop.key for prop in mapper.column_attrs)
except Exception:
names: list[str] = []
for c in cls.__mro__:
if hasattr(c, "__table__"):
names.extend(col.name for col in c.__table__.columns)
return tuple(dict.fromkeys(names))
def _sa_state(obj: Any) -> Optional[InstanceState[Any]]:
"""Safely get SQLAlchemy InstanceState (or None)."""
try: try:
st = inspect(obj) st = inspect(obj)
attr = st.attrs.get(name) return cast(Optional[InstanceState[Any]], st)
except Exception:
return None
def _sa_mapper(obj: Any) -> Optional[Mapper]:
"""Safely get Mapper for a maooed instance (or None)."""
try:
st = inspect(obj)
mapper = getattr(st, "mapper", None)
return cast(Optional[Mapper], mapper)
except Exception:
return None
def _safe_get_loaded_attr(obj, name):
st = _sa_state(obj)
if st is None:
return None
try:
st_dict = getattr(st, "dict", {})
if name in st_dict:
return st_dict[name]
attrs = getattr(st, "attrs", None)
attr = None
if attrs is not None:
try:
attr = attrs[name]
except Exception:
try:
get = getattr(attrs, "get", None)
if callable(get):
attr = get(name)
except Exception:
attr = None
if attr is not None: if attr is not None:
val = attr.loaded_value val = attr.loaded_value
return None if val is NO_VALUE else val return None if val is NO_VALUE else val
if name in st.dict:
return st.dict.get(name)
return None return None
except Exception: except Exception:
return None return None
@ -33,10 +81,7 @@ def _is_collection_rel(prop: RelationshipProperty) -> bool:
def _serialize_simple_obj(obj) -> Dict[str, Any]: def _serialize_simple_obj(obj) -> Dict[str, Any]:
"""Columns only (no relationships).""" """Columns only (no relationships)."""
out: Dict[str, Any] = {} out: Dict[str, Any] = {}
for cls in obj.__class__.__mro__: for name in _column_names_for_model(type(obj)):
if hasattr(cls, "__table__"):
for col in cls.__table__.columns:
name = col.name
try: try:
out[name] = getattr(obj, name) out[name] = getattr(obj, name)
except Exception: except Exception:
@ -204,12 +249,16 @@ class CRUDMixin:
# Determine which relationships to consider # Determine which relationships to consider
try: try:
st = inspect(self) mapper = _sa_mapper(self)
mapper = st.mapper embed_set = set(str(x).split(".", 1)[0] for x in (embed or []))
embed_set = set(str(x).split(".", 1)[0] for x in (embed or [])) # top-level names if mapper is None:
return data
st = _sa_state(self)
if st is None:
return data
for name, prop in mapper.relationships.items(): for name, prop in mapper.relationships.items():
# Only touch relationships that are already loaded; never lazy-load here. # Only touch relationships that are already loaded; never lazy-load here.
rel_loaded = st.attrs.get(name) rel_loaded = getattr(st, "attrs", {}).get(name)
if rel_loaded is None or rel_loaded.loaded_value is NO_VALUE: if rel_loaded is None or rel_loaded.loaded_value is NO_VALUE:
continue continue
@ -266,13 +315,10 @@ class CRUDMixin:
val = None val = None
# If it's a scalar ORM object (relationship), serialize its columns # If it's a scalar ORM object (relationship), serialize its columns
try: mapper = _sa_mapper(val)
st = inspect(val) # will raise if not an ORM object if mapper is not None:
if getattr(st, "mapper", None) is not None:
out[name] = _serialize_simple_obj(val) out[name] = _serialize_simple_obj(val)
continue continue
except Exception:
pass
# If it's a collection and no subfields were requested, emit a light list # If it's a collection and no subfields were requested, emit a light list
if isinstance(val, (list, tuple)): if isinstance(val, (list, tuple)):

View file

@ -639,9 +639,14 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
"template": spec.get("template"), "template": spec.get("template"),
"template_name": spec.get("template_name"), "template_name": spec.get("template_name"),
"template_ctx": spec.get("template_ctx"), "template_ctx": spec.get("template_ctx"),
"label_spec": spec.get("label_spec") "label_spec": spec.get("label_spec"),
} }
if "link" in spec:
field["link"] = spec["link"]
if "label_deps" in spec:
field["label_deps"] = spec["label_deps"]
if rel_prop: if rel_prop:
if field["type"] is None: if field["type"] is None:
field["type"] = "select" field["type"] = "select"
@ -1061,6 +1066,7 @@ def render_field(field, value):
label_attrs=_sanitize_attrs(field.get('label_attrs') or {}), label_attrs=_sanitize_attrs(field.get('label_attrs') or {}),
help=field.get('help'), help=field.get('help'),
value_label=field.get('value_label'), value_label=field.get('value_label'),
link_href=field.get("link_href"),
) )
@ -1231,6 +1237,15 @@ def render_form(
if vl2 is not None: if vl2 is not None:
f["value_label"] = vl2 f["value_label"] = vl2
link_spec = f.get("link")
if link_spec:
try:
href = _build_href(link_spec, values_map, instance)
except Exception:
href = None
if href:
f["link_href"] = href
# Build rows (supports nested layout with parents) # Build rows (supports nested layout with parents)
rows_map = _normalize_rows_layout(layout) rows_map = _normalize_rows_layout(layout)
rows_tree = _assign_fields_to_rows(fields, rows_map) rows_tree = _assign_fields_to_rows(fields, rows_map)

View file

@ -4,7 +4,13 @@
{% if label_attrs %}{% for k,v in label_attrs.items() %} {% if label_attrs %}{% for k,v in label_attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %} {{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}> {% endfor %}{% endif %}>
{% if link_href %}
<a href="{{ link_href }}">
{% endif %}
{{ field_label }} {{ field_label }}
{% if link_href %}
</a>
{% endif %}
</label> </label>
{% endif %} {% endif %}