diff --git a/crudkit/core/base.py b/crudkit/core/base.py index 46874fe..5501e19 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -1,8 +1,21 @@ -from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func -from sqlalchemy.orm import declarative_mixin, declarative_base +from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect +from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE Base = declarative_base() +def _safe_get_loaded_attr(obj, name): + try: + st = inspect(obj) + attr = st.attrs.get(name) + if attr is not None: + val = attr.loaded_value + return None if val is NO_VALUE else val + if name in st.dict: + return st.dict.get(name) + return None + except Exception: + return None + @declarative_mixin class CRUDMixin: id = Column(Integer, primary_key=True) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index c34bc2d..b84ebc6 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -3,6 +3,7 @@ from sqlalchemy import and_, func, inspect, or_, text from sqlalchemy.engine import Engine, Connection from sqlalchemy.orm import Load, Session, raiseload, selectinload, with_polymorphic, Mapper, RelationshipProperty, class_mapper, ColumnProperty from sqlalchemy.orm.attributes import InstrumentedAttribute +from sqlalchemy.orm.base import NO_VALUE from sqlalchemy.orm.util import AliasedClass from sqlalchemy.sql import operators from sqlalchemy.sql.elements import UnaryExpression @@ -12,6 +13,39 @@ from crudkit.core.spec import CRUDSpec from crudkit.core.types import OrderSpec, SeekWindow from crudkit.backend import BackendInfo, make_backend_info +def _expand_requires(model_cls, fields): + out, seen = [], set() + def add(f): + if f not in seen: + seen.add(f); out.append(f) + + for f in fields: + add(f) + parts = f.split(".") + cur_cls = model_cls + prefix = [] + + for p in parts[:-1]: + rel = getattr(cur_cls.__mapper__.relationships, 'get', lambda _: None)(p) + if not rel: + cur_cls = None + break + cur_cls = rel.mapper.class_ + prefix.append(p) + + if cur_cls is None: + continue + + leaf = parts[-1] + deps = (getattr(cur_cls, "__crudkit_field_requires__", {}) or {}).get(leaf) + if not deps: + continue + + pre = ".".join(prefix) + for dep in deps: + add(f"{pre + '.' if pre else ''}{dep}") + return out + def _is_rel(model_cls, name: str) -> bool: try: prop = model_cls.__mapper__.relationships.get(name) @@ -232,7 +266,10 @@ class CRUDService(Generic[T]): - forward/backward seek via `key` and `backward` Returns a SeekWindow with items, first/last keys, order spec, limit, and optional total. """ - session = self.session + fields = list(params.get("fields", [])) + if fields: + fields = _expand_requires(self.model, fields) + params = {**params, "fields": fields} query, root_alias = self.get_query() spec = CRUDSpec(self.model, params or {}, root_alias) diff --git a/crudkit/projection.py b/crudkit/projection.py new file mode 100644 index 0000000..b34091d --- /dev/null +++ b/crudkit/projection.py @@ -0,0 +1,236 @@ +# crudkit/projection.py +from __future__ import annotations +from typing import Iterable, List, Tuple, Dict, Set +from sqlalchemy.orm import selectinload +from sqlalchemy.orm.attributes import InstrumentedAttribute +from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty +from sqlalchemy import inspect + +# ---------------------- +# small utilities +# ---------------------- + +def _is_column_attr(a) -> bool: + try: + return isinstance(a, InstrumentedAttribute) and isinstance(a.property, ColumnProperty) + except Exception: + return False + +def _is_relationship_attr(a) -> bool: + try: + return isinstance(a, InstrumentedAttribute) and isinstance(a.property, RelationshipProperty) + except Exception: + return False + +def _split_path(field: str) -> List[str]: + return [p for p in str(field).split(".") if p] + +def _model_requires_map(model_cls) -> Dict[str, List[str]]: + # apps declare per-model deps, e.g. {"label": ["first_name","last_name","title"]} + return getattr(model_cls, "__crudkit_field_requires__", {}) or {} + +def _relationships_of(model_cls) -> Dict[str, RelationshipProperty]: + try: + return dict(model_cls.__mapper__.relationships) + except Exception: + return {} + +def _attr_on(model_cls, name: str): + return getattr(model_cls, name, None) + +# ---------------------- +# EXPAND: add required deps for leaf attributes at the correct class +# ---------------------- + +def _expand_requires_for_field(model_cls, pieces: List[str]) -> List[str]: + """ + Given a dotted path like ["owner","label"], walk relationships to the leaf *container* class, + pull its __crudkit_field_requires__ for that leaf attr ("label"), and yield prefixed deps: + owner.label -> ["owner.first_name", "owner.last_name", ...] if User requires so. + If leaf is a column (or has no requires), returns []. + """ + if not pieces: + return [] + + # walk relationships to the leaf container (class that owns the leaf attr) + container_cls = model_cls + prefix_parts: List[str] = [] + for part in pieces[:-1]: + a = _attr_on(container_cls, part) + if not _is_relationship_attr(a): + return [] # can't descend; invalid or scalar in the middle + container_cls = a.property.mapper.class_ + prefix_parts.append(part) + + leaf = pieces[-1] + requires = _model_requires_map(container_cls).get(leaf) or [] + if not requires: + return [] + + prefix = ".".join(prefix_parts) + out: List[str] = [] + for dep in requires: + # dep may itself be dotted relative to container (e.g. "room_function.description") + if prefix: + out.append(f"{prefix}.{dep}") + else: + out.append(dep) + return out + +def _expand_requires(model_cls, fields: Iterable[str]) -> List[str]: + """ + Dedup + stable expansion of requires for all fields. + """ + seen: Set[str] = set() + out: List[str] = [] + + def add(f: str): + if f not in seen: + seen.add(f) + out.append(f) + + # first pass: add original + queue: List[str] = [] + for f in fields: + f = str(f) + if f not in seen: + seen.add(f) + out.append(f) + queue.append(f) + + # BFS-ish: when we add deps, they may trigger further deps downstream + while queue: + f = queue.pop(0) + deps = _expand_requires_for_field(model_cls, _split_path(f)) + for d in deps: + if d not in seen: + seen.add(d) + out.append(d) + queue.append(d) + + return out + +# ---------------------- +# BUILD loader options tree with selectinload + load_only on real columns +# ---------------------- + +def _insert_leaf(loader_tree: dict, path: List[str]): + """ + Build nested dict structure keyed by relationship names. + Each node holds: + { + "__cols__": set(column_names_to_load_only), + "": { ... } + } + """ + node = loader_tree + for rel in path[:-1]: # only relationship hops + node = node.setdefault(rel, {"__cols__": set()}) + # leaf may be a column or a virtual/hybrid; only columns go to __cols__ + node.setdefault("__cols__", set()) + +def _attach_column(loader_tree: dict, path: List[str], model_cls): + """ + If the leaf is a real column on the target class, record its name into __cols__ at that level. + """ + # descend to target class to test column-ness + container_cls = model_cls + node = loader_tree + for rel in path[:-1]: + a = _attr_on(container_cls, rel) + if not _is_relationship_attr(a): + return # invalid path, ignore + container_cls = a.property.mapper.class_ + node = node.setdefault(rel, {"__cols__": set()}) + + leaf = path[-1] + a_leaf = _attr_on(container_cls, leaf) + node.setdefault("__cols__", set()) + if _is_column_attr(a_leaf): + node["__cols__"].add(leaf) + +def _build_loader_tree(model_cls, fields: Iterable[str]) -> dict: + """ + For each dotted field: + - walk relationships -> create nodes + - if leaf is a column: record it for load_only + - if leaf is not a column (hybrid/descriptor): no load_only; still ensure rel hops exist + """ + tree: Dict[str, dict] = {"__cols__": set()} + for f in fields: + parts = _split_path(f) + if not parts: + continue + # ensure relationship nodes exist + _insert_leaf(tree, parts) + # attach column if applicable + _attach_column(tree, parts, model_cls) + return tree + +def _loader_options_from_tree(model_cls, tree: dict): + """ + Convert the loader tree into SQLAlchemy loader options: + selectinload()[.load_only(cols)] recursively + """ + opts = [] + + rels = _relationships_of(model_cls) + for rel_name, child in tree.items(): + if rel_name == "__cols__": + continue + rel_prop = rels.get(rel_name) + if not rel_prop: + continue + rel_attr = getattr(model_cls, rel_name) + opt = selectinload(rel_attr) + + # apply load_only on the related class (only real columns recorded at child["__cols__"]) + cols = list(child.get("__cols__", [])) + if cols: + rel_model = rel_prop.mapper.class_ + # map column names to attributes + col_attrs = [] + for c in cols: + a = getattr(rel_model, c, None) + if _is_column_attr(a): + col_attrs.append(a) + if col_attrs: + opt = opt.load_only(*col_attrs) + + # recurse to grandchildren + sub_opts = _loader_options_from_tree(rel_prop.mapper.class_, child) + for so in sub_opts: + opt = opt.options(so) + + opts.append(opt) + + # root-level columns (rare in our compile; kept for completeness) + root_cols = list(tree.get("__cols__", [])) + if root_cols: + # NOTE: call-site can add a root load_only(...) if desired; + # we purposely return only relationship options here to keep + # the API simple and avoid mixing Load(model_cls) contexts. + pass + + return opts + +# ---------------------- +# PUBLIC API +# ---------------------- + +def compile_projection(model_cls, fields: Iterable[str]) -> Tuple[List[str], List]: + """ + Returns: + expanded_fields: List[str] # original + declared dependencies + loader_options: List[Load] # apply via query = query.options(*loader_options) + + Behavior: + - Expands __crudkit_field_requires__ at the leaf container class for every field. + - Builds a selectinload tree; load_only only includes real columns (no hybrids). + - Safe for nested paths: e.g. "owner.label" pulls owner deps from User.__crudkit_field_requires__. + """ + fields = list(fields or []) + expanded = _expand_requires(model_cls, fields) + tree = _build_loader_tree(model_cls, expanded) + options = _loader_options_from_tree(model_cls, tree) + return expanded, options diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index a26bf0b..6c62c45 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -19,6 +19,52 @@ _ALLOWED_ATTRS = { "id", "name", "value", } +_SAFE_CSS_PROPS = { + # spacing / sizing + "margin","margin-top","margin-right","margin-bottom","margin-left", + "padding","padding-top","padding-right","padding-bottom","padding-left", + "width","height","min-width","min-height","max-width","max-height", "resize", + # layout + "display","flex","flex-direction","flex-wrap","justify-content","align-items","gap", + # text + "font-size","font-weight","line-height","text-align","white-space", + # colors / background + "color","background-color", + # borders / radius + "border","border-top","border-right","border-bottom","border-left", + "border-width","border-style","border-color","border-radius", + # misc (safe-ish) + "opacity","overflow","overflow-x","overflow-y", +} + +_num_unit = r"-?\d+(?:\.\d+)?" +_len_unit = r"(?:px|em|rem|%)" +P_LEN = re.compile(rf"^{_num_unit}(?:{_len_unit})?$") # 12, 12px, 1.2rem, 50% +P_GAP = P_LEN +P_INT = re.compile(r"^\d+$") +P_COLOR = re.compile( + r"^(#[0-9a-fA-F]{3,8}|" + r"rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|" + r"rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(?:0|1|0?\.\d+)\s*\)|" + r"[a-zA-Z]+)$" +) + +_ENUMS = { + "display": {"block","inline","inline-block","flex","grid","none"}, + "flex-direction": {"row","row-reverse","column","column-reverse"}, + "flex-wrap": {"nowrap","wrap","wrap-reverse"}, + "justify-content": {"flex-start","flex-end","center","space-between","space-around","space-evenly"}, + "align-items": {"stretch","flex-start","flex-end","center","baseline"}, + "text-align": {"left","right","center","justify","start","end"}, + "white-space": {"normal","nowrap","pre","pre-wrap","pre-line","break-spaces"}, + "border-style": {"none","solid","dashed","dotted","double","groove","ridge","inset","outset"}, + "overflow": {"visible","hidden","scroll","auto","clip"}, + "overflow-x": {"visible","hidden","scroll","auto","clip"}, + "overflow-y": {"visible","hidden","scroll","auto","clip"}, + "font-weight": {"normal","bold","bolder","lighter","100","200","300","400","500","600","700","800","900"}, + "resize": {"none", "both", "horizontal", "vertical"}, +} + def get_env(): app = current_app default_path = os.path.join(os.path.dirname(__file__), 'templates') @@ -28,6 +74,79 @@ def get_env(): loader=ChoiceLoader([app.jinja_loader, fallback_loader]) ) +def expand_projection(model_cls, fields): + req = getattr(model_cls, "__crudkit_field_requires__", {}) or {} + out = set(fields) + for f in list(fields): + for dep in req.get(f, ()): + out.add(dep) + return list(out) + +def _clean_css_value(prop: str, raw: str) -> str | None: + v = raw.strip() + + v = v.replace("!important", "") + low = v.lower() + if any(bad in low for bad in ("url(", "expression(", "javascript:", "var(")): + return None + + if prop in {"width","height","min-width","min-height","max-width","max-height", + "margin","margin-top","margin-right","margin-bottom","margin-left", + "padding","padding-top","padding-right","padding-bottom","padding-left", + "border-width","border-top","border-right","border-bottom","border-left","border-radius", + "line-height","font-size"}: + return v if P_LEN.match(v) else None + + if prop in {"gap"}: + parts = [p.strip() for p in v.split()] + if 1 <= len(parts) <= 2 and all(P_GAP.match(p) for p in parts): + return " ".join(parts) + return None + + if prop in {"color", "background-color", "border-color"}: + return v if P_COLOR.match(v) else None + + if prop in _ENUMS: + return v if v.lower() in _ENUMS[prop] else None + + if prop == "flex": + toks = v.split() + if len(toks) == 1 and (toks[0].isdigit() or toks[0] in {"auto", "none"}): + return v + if len(toks) == 2 and toks[0].isdigit() and (toks[1].isdigit() or toks[1] == "auto"): + return v + if len(toks) == 3 and toks[0].isdigit() and toks[1].isdigit() and (P_LEN.match(toks[2]) or toks[2] == "auto"): + return " ".join(toks) + return None + + if prop == "border": + parts = v.split() + bw = next((p for p in parts if P_LEN.match(p)), None) + bs = next((p for p in parts if p in _ENUMS["border-style"]), None) + bc = next((p for p in parts if P_COLOR.match(p)), None) + chosen = [x for x in (bw, bs, bc) if x] + return " ".join(chosen) if chosen else None + + return None + +def _sanitize_style(style: str | None) -> str | None: + if not style or not isinstance(style, str): + return None + safe_decls = [] + for chunk in style.split(";"): + if not chunk.strip(): + continue + if ":" not in chunk: + continue + prop, val = chunk.split(":", 1) + prop = prop.strip().lower() + if prop not in _SAFE_CSS_PROPS: + continue + clean = _clean_css_value(prop, val) + if clean is not None and clean != "": + safe_decls.append(f"{prop}: {clean}") + return "; ".join(safe_decls) if safe_decls else None + def _is_column_attr(attr) -> bool: try: return isinstance(attr, InstrumentedAttribute) and isinstance(attr.property, ColumnProperty) @@ -217,6 +336,11 @@ def _sanitize_attrs(attrs: Any) -> dict[str, Any]: elif isinstance(v, str): if len(v) > 512: v = v[:512] + if k == "style": + sv = _sanitize_style(v) + if sv: + out["style"] = sv + continue if k.startswith("data-") or k.startswith("aria-") or k in _ALLOWED_ATTRS: if isinstance(v, bool): if v: diff --git a/inventory/models/user.py b/inventory/models/user.py index 241cb3d..6ecc432 100644 --- a/inventory/models/user.py +++ b/inventory/models/user.py @@ -19,7 +19,7 @@ class User(Base, CRUDMixin): __tablename__ = 'users' __crud_label__ = "{label}" __crudkit_field_requires__ = { - "label": ["first_name", "last_name", "title"] # whatever the hybrid touches + "label": ["first_name", "last_name", "title"] } first_name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True, index=True) diff --git a/inventory/routes/entry.py b/inventory/routes/entry.py index dba0426..2285bac 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -54,7 +54,7 @@ def init_entry_routes(app): "wrap": {"class": "h-100 w-100"}}, {"name": "notes", "type": "textarea", "label": "Notes", "row": "notes", "wrap": {"class": "col"}, - "attrs": {"class": "form-control", "rows": 10}, "label_attrs": {"class": "form-label"}}, + "attrs": {"class": "form-control", "rows": 10, "style": "resize: none;"}, "label_attrs": {"class": "form-label"}}, ] layout = [ {"name": "label", "order": 5}, diff --git a/inventory/templates/base.html b/inventory/templates/base.html index b910bb1..a498bee 100644 --- a/inventory/templates/base.html +++ b/inventory/templates/base.html @@ -33,6 +33,10 @@ {% block header %} {% endblock %} +
+ + +