Downstream changes.
This commit is contained in:
parent
f956e09e2b
commit
ec82ca2394
4 changed files with 261 additions and 181 deletions
|
|
@ -2,181 +2,194 @@
|
||||||
|
|
||||||
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):
|
||||||
def _safe_int(value: str | None, default: int) -> int:
|
|
||||||
try:
|
|
||||||
return int(value) if value is not None else default
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _link_with_params(base_url: str, **params) -> str:
|
|
||||||
q = {k: v for k, v in params.items() if v is not None}
|
|
||||||
return f"{base_url}?{urlencode(q)}"
|
|
||||||
|
|
||||||
|
|
||||||
def generate_crud_blueprint(model, service, *, base_prefix: str | None = None):
|
|
||||||
"""
|
"""
|
||||||
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()
|
RPC (legacy):
|
||||||
GET /api/<model>/list?... -> service.list()
|
GET /api/<model>/get?id=123
|
||||||
GET /api/<model>/seek_window?... -> service.seek_window()
|
GET /api/<model>/list
|
||||||
GET /api/<model>/page?page=2&per_page=50&... -> service.page()
|
GET /api/<model>/seek_window
|
||||||
|
GET /api/<model>/page
|
||||||
POST /api/<model>/create -> service.create(payload)
|
POST /api/<model>/create
|
||||||
|
PATCH /api/<model>/update?id=123
|
||||||
PATCH /api/<model>/update?id=123 -> service.update(id, payload)
|
DELETE /api/<model>/delete?id=123[&hard=1]
|
||||||
|
|
||||||
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()
|
model_name = model.__name__.lower()
|
||||||
bp = Blueprint(name, __name__, url_prefix=f"/api/{name}")
|
# 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")
|
@bp.errorhandler(Exception)
|
||||||
def rpc_get():
|
def _handle_any(e: Exception):
|
||||||
id_ = _safe_int(request.args.get("id"), 0)
|
return _json_error(e)
|
||||||
if not id_:
|
|
||||||
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
@bp.errorhandler(404)
|
||||||
try:
|
def _not_found(_e):
|
||||||
item = service.get(id_, request.args)
|
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:
|
||||||
|
items = service.list(args)
|
||||||
|
return jsonify([o.as_dict() for o in items])
|
||||||
|
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:
|
if item is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
return jsonify(item.as_dict())
|
etag = _etag_for(item)
|
||||||
except Exception as e:
|
if request.if_none_match and (etag in request.if_none_match):
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
return "", 304
|
||||||
|
resp = jsonify(item.as_dict())
|
||||||
@bp.get("/list")
|
resp.set_etag(etag)
|
||||||
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,
|
|
||||||
)
|
|
||||||
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
|
return resp
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
|
||||||
|
|
||||||
@bp.get("/page")
|
@bp.post("/")
|
||||||
def rpc_page():
|
def rest_create():
|
||||||
args = request.args.to_dict(flat=True)
|
payload = _json_payload()
|
||||||
page = _safe_int(args.get("page"), 1)
|
try:
|
||||||
per_page = _safe_int(args.get("per_page"), 50)
|
obj = service.create(payload)
|
||||||
include_total = _bool_param(args, "include_total", True)
|
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)
|
||||||
|
|
||||||
try:
|
@bp.patch("/<int:obj_id>")
|
||||||
result = service.page(args, page=page, per_page=per_page, include_total=include_total)
|
def rest_update(obj_id: int):
|
||||||
# Already includes: items, page, per_page, total, pages, order
|
payload = _json_payload()
|
||||||
# Items come back as model instances; serialize to dicts
|
try:
|
||||||
result = {
|
obj = service.update(obj_id, payload)
|
||||||
**result,
|
return jsonify(obj.as_dict())
|
||||||
"items": [obj.as_dict() for obj in result["items"]],
|
except Exception as e:
|
||||||
}
|
return _json_error(e)
|
||||||
return jsonify(result)
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
|
||||||
|
|
||||||
# -------- WRITES --------
|
@bp.delete("/<int:obj_id>")
|
||||||
|
def rest_delete(obj_id: int):
|
||||||
|
hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type]
|
||||||
|
try:
|
||||||
|
obj = service.delete(obj_id, hard=hard)
|
||||||
|
if obj is None:
|
||||||
|
abort(404)
|
||||||
|
return ("", 204)
|
||||||
|
except Exception as e:
|
||||||
|
return _json_error(e)
|
||||||
|
|
||||||
@bp.post("/create")
|
# ---------- RPC (your existing routes) ----------
|
||||||
def rpc_create():
|
if rpc:
|
||||||
payload = request.get_json(silent=True) or {}
|
@bp.get("/get")
|
||||||
try:
|
def rpc_get():
|
||||||
obj = service.create(payload)
|
print("⚠️ WARNING: Deprecated RPC call used: /get")
|
||||||
return jsonify(obj.as_dict()), 201
|
id_ = int(request.args.get("id", 0))
|
||||||
except Exception as e:
|
if not id_:
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
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 _json_error(e)
|
||||||
|
|
||||||
@bp.patch("/update")
|
@bp.get("/list")
|
||||||
def rpc_update():
|
def rpc_list():
|
||||||
id_ = _safe_int(request.args.get("id"), 0)
|
print("⚠️ WARNING: Deprecated RPC call used: /list")
|
||||||
if not id_:
|
args = _args_flat()
|
||||||
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
try:
|
||||||
payload = request.get_json(silent=True) or {}
|
items = service.list(args)
|
||||||
try:
|
return jsonify([obj.as_dict() for obj in items])
|
||||||
obj = service.update(id_, payload)
|
except Exception as e:
|
||||||
return jsonify(obj.as_dict())
|
return _json_error(e)
|
||||||
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.delete("/delete")
|
@bp.post("/create")
|
||||||
def rpc_delete():
|
def rpc_create():
|
||||||
id_ = _safe_int(request.args.get("id"), 0)
|
print("⚠️ WARNING: Deprecated RPC call used: /create")
|
||||||
if not id_:
|
payload = _json_payload()
|
||||||
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
try:
|
||||||
hard = _bool_param(request.args, "hard", False)
|
obj = service.create(payload)
|
||||||
try:
|
return jsonify(obj.as_dict()), 201
|
||||||
obj = service.delete(id_, hard=hard)
|
except Exception as e:
|
||||||
# 204 if actually deleted or soft-deleted; return body if you feel chatty
|
return _json_error(e)
|
||||||
return ("", 204) if obj is not None else abort(404)
|
|
||||||
except Exception as e:
|
@bp.patch("/update")
|
||||||
return jsonify({"status": "error", "error": str(e)}), 400
|
def rpc_update():
|
||||||
|
print("⚠️ WARNING: Deprecated RPC call used: /update")
|
||||||
|
id_ = int(request.args.get("id", 0))
|
||||||
|
if not id_:
|
||||||
|
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
||||||
|
payload = _json_payload()
|
||||||
|
try:
|
||||||
|
obj = service.update(id_, payload)
|
||||||
|
return jsonify(obj.as_dict())
|
||||||
|
except Exception as e:
|
||||||
|
return _json_error(e)
|
||||||
|
|
||||||
|
@bp.delete("/delete")
|
||||||
|
def rpc_delete():
|
||||||
|
print("⚠️ WARNING: Deprecated RPC call used: /delete")
|
||||||
|
id_ = int(request.args.get("id", 0))
|
||||||
|
if not id_:
|
||||||
|
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
||||||
|
hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type]
|
||||||
|
try:
|
||||||
|
obj = service.delete(id_, hard=hard)
|
||||||
|
return ("", 204) if obj is not None else abort(404)
|
||||||
|
except Exception as e:
|
||||||
|
return _json_error(e)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|
|
||||||
|
|
@ -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,14 +81,11 @@ 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__"):
|
try:
|
||||||
for col in cls.__table__.columns:
|
out[name] = getattr(obj, name)
|
||||||
name = col.name
|
except Exception:
|
||||||
try:
|
out[name] = None
|
||||||
out[name] = getattr(obj, name)
|
|
||||||
except Exception:
|
|
||||||
out[name] = None
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _serialize_loaded_rel(obj, name, *, depth: int, seen: Set[Tuple[type, Any]], embed: Set[str]) -> Any:
|
def _serialize_loaded_rel(obj, name, *, depth: int, seen: Set[Tuple[type, Any]], embed: Set[str]) -> Any:
|
||||||
|
|
@ -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)):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 %}>
|
||||||
{{ field_label }}
|
{% if link_href %}
|
||||||
|
<a href="{{ link_href }}">
|
||||||
|
{% endif %}
|
||||||
|
{{ field_label }}
|
||||||
|
{% if link_href %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</label>
|
</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue