diff --git a/crudkit/core/base.py b/crudkit/core/base.py index 5501e19..46874fe 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -1,21 +1,8 @@ -from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect -from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE +from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func +from sqlalchemy.orm import declarative_mixin, declarative_base 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 b84ebc6..c34bc2d 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -3,7 +3,6 @@ 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 @@ -13,39 +12,6 @@ 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) @@ -266,10 +232,7 @@ 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. """ - fields = list(params.get("fields", [])) - if fields: - fields = _expand_requires(self.model, fields) - params = {**params, "fields": fields} + session = self.session query, root_alias = self.get_query() spec = CRUDSpec(self.model, params or {}, root_alias) diff --git a/crudkit/projection.py b/crudkit/projection.py deleted file mode 100644 index b34091d..0000000 --- a/crudkit/projection.py +++ /dev/null @@ -1,236 +0,0 @@ -# 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 6c62c45..a26bf0b 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -19,52 +19,6 @@ _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') @@ -74,79 +28,6 @@ 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) @@ -336,11 +217,6 @@ 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 6ecc432..241cb3d 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"] + "label": ["first_name", "last_name", "title"] # whatever the hybrid touches } 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 2285bac..dba0426 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, "style": "resize: none;"}, "label_attrs": {"class": "form-label"}}, + "attrs": {"class": "form-control", "rows": 10}, "label_attrs": {"class": "form-label"}}, ] layout = [ {"name": "label", "order": 5}, diff --git a/inventory/routes/listing.py b/inventory/routes/listing.py index 56b1601..1cac645 100644 --- a/inventory/routes/listing.py +++ b/inventory/routes/listing.py @@ -10,6 +10,7 @@ bp_listing = Blueprint("listing", __name__) def init_listing_routes(app): @bp_listing.get("/listing/") def show_list(model): + page_num = int(request.args.get("page", 1)) if model.lower() not in {"inventory", "user", "worklog"}: abort(404) diff --git a/inventory/templates/base.html b/inventory/templates/base.html index a498bee..b910bb1 100644 --- a/inventory/templates/base.html +++ b/inventory/templates/base.html @@ -33,10 +33,6 @@ {% block header %} {% endblock %} -
- - -