diff --git a/crudkit/eager.py b/crudkit/eager.py index f32efc2..34e7884 100644 --- a/crudkit/eager.py +++ b/crudkit/eager.py @@ -1,8 +1,21 @@ -from typing import List +from __future__ import annotations +from typing import Iterable, List, Sequence, Set from sqlalchemy.inspection import inspect -from sqlalchemy.orm import Load, joinedload, selectinload +from sqlalchemy.orm import Load, joinedload, selectinload, RelationshipProperty -def default_eager_policy(Model, expand: List[str]) -> List[Load]: +class EagerConfig: + def __init__(self, strict: bool = False, max_depth: int = 4): + self.strict = strict + self.max_depth = max_depth + +def _rel(cls, name: str) -> RelationshipProperty | None: + return inspect(cls).relationships.get(name) + +def _is_expandable(rel: RelationshipProperty) -> bool: + # Skip dynamic or viewonly collections; they don’t support eagerload + return rel.lazy != "dynamic" + +def default_eager_policy(Model, expand: Sequence[str], cfg: EagerConfig | None = None) -> List[Load]: """ Heuristic: - many-to-one / one-to-one: joinedload @@ -12,31 +25,51 @@ def default_eager_policy(Model, expand: List[str]) -> List[Load]: if not expand: return [] + cfg = cfg or EagerConfig() + # normalize, dedupe, and prefer longer paths over their prefixes + raw: Set[str] = {p.strip() for p in expand if p and p.strip()} + # drop prefixes if a longer path exists (author, author.publisher -> keep only author.publisher) + pruned: Set[str] = set(raw) + for p in raw: + parts = p.split(".") + for i in range(1, len(parts)): + pruned.discard(".".join(parts[:i])) + opts: List[Load] = [] + seen: Set[tuple] = set() - for path in expand: + for path in sorted(pruned): parts = path.split(".") + if len(parts) > cfg.max_depth: + if cfg.strict: + raise ValueError(f"expand path too deep: {path} (max {cfg.max_depth})") + continue + current_model = Model - current_inspect = inspect(current_model) + # build the chain incrementally + loader: Load | None = None + ok = True - # first hop - rel = current_inspect.relationships.get(parts[0]) - if not rel: - continue # silently skip bad names - attr = getattr(current_model, parts[0]) - loader: Load = selectinload(attr) if rel.uselist else joinedload(attr) - current_model = rel.mapper.class_ - - # nested hops, if any - for name in parts[1:]: - current_inspect = inspect(current_model) - rel = current_inspect.relationships.get(name) - if not rel: + for i, name in enumerate(parts): + rel = _rel(current_model, name) + if not rel or not _is_expandable(rel): + ok = False break attr = getattr(current_model, name) - loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr) + if loader is None: + loader = selectinload(attr) if rel.uselist else joinedload(attr) + else: + loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr) current_model = rel.mapper.class_ - opts.append(loader) + if not ok: + if cfg.strict: + raise ValueError(f"unknown or non-expandable relationship in expand path: {path}") + continue + + key = (tuple(parts),) + if loader is not None and key not in seen: + opts.append(loader) + seen.add(key) return opts diff --git a/crudkit/html/templates/crudkit/_macros.html b/crudkit/html/templates/crudkit/_macros.html index 4dbf0fc..bac6692 100644 --- a/crudkit/html/templates/crudkit/_macros.html +++ b/crudkit/html/templates/crudkit/_macros.html @@ -18,11 +18,13 @@ {%- endfor -%} {% endmacro %} -{% macro rows(items, fields, getp=None) -%} +{% macro rows(items, fields, getp=None, model=None) -%} {%- for obj in items -%} {%- for f in fields -%} - {{ getp(obj, f) if getp(obj, f) is not none else '-' }} + {{ getp(obj, f) if + getp(obj, f) is not none else '-' }} {%- endfor -%} {%- else -%} @@ -32,101 +34,133 @@ {%- endfor -%} {%- endmacro %} -{% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%} -{# - -#} -{% set p = page|int %} -{% set pg = pages|int %} -{% set prev = [1, p-1]|max %} -{% set nxt = [pg, p+1]|min %} - - +{# helper: build hx-get URL with shared params #} +{% macro _rows_url(model, page, per_page, sort, filters, fields_csv) -%} +/ui/{{ model }}/frag/rows +?page={{ page }}&per_page={{ per_page }} +{%- if sort %}&sort={{ sort }}{% endif -%} +{%- if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif -%} +{%- for k, v in (filters or {}).items() %}&{{ k }}={{ v|urlencode }}{% endfor -%} {%- endmacro %} -{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%} -
- {%- if csrf_token %}{% endif -%} - {%- if obj_id %}{% endif -%} - +{# helper: render one page link or a disabled/current state #} +{% macro _page_li(model, label, page, current_page, per_page, sort, filters, fields_csv, attrs='') -%} +{% if page is none %} +
  • {{ label }}
  • +{% elif page == current_page and label is string and label|int(default=None) is none %} +{# non-numeric current like "…" shouldn't happen, but be safe #} +
  • {{ label }}
  • +{% elif page == current_page %} +
  • + {{ label }} +
  • +{% else %} +
  • + + {{ label }} + +
  • +{% endif %} +{%- endmacro %} - {%- for f in schema -%} -
    - {% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %} - - {%- if f.type == "textarea" -%} - - {%- elif f.type == "select" -%} - - {%- elif f.type == "checkbox" -%} - - - {%- else -%} - - {%- endif -%} - {%- if f.help %}
    {{ f.help }}
    {% endif -%} -
    - {%- endfor -%} +{% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%} +{% set p = page|int %} +{% set pg = pages|int %} +{% set prev = 1 if p <= 1 else p - 1 %} {% set nxt=pg if p>= pg else p + 1 %} -
    - -
    -
    -{%- endmacro %} \ No newline at end of file + {# window of pages around current #} + {% set win = 2 %} + {% set left = [2, p - win]|max %} + {% set right = [pg - 1, p + win]|min %} + + + + {# keep hidden pager-state in sync with one handler instead of inline click spam #} + + {%- endmacro %} + + {% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%} +
    + {%- if csrf_token %}{% endif -%} + {%- if obj_id %}{% endif -%} + + + {%- for f in schema -%} +
    + {% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %} + + {%- if f.type == "textarea" -%} + + {%- elif f.type == "select" -%} + + {%- elif f.type == "checkbox" -%} + + + {%- else -%} + + {%- endif -%} + {%- if f.help %}
    {{ f.help }}
    {% endif -%} +
    + {%- endfor -%} + +
    + +
    +
    + {%- endmacro %} \ No newline at end of file diff --git a/crudkit/html/templates/crudkit/rows.html b/crudkit/html/templates/crudkit/rows.html index 7784328..59155b6 100644 --- a/crudkit/html/templates/crudkit/rows.html +++ b/crudkit/html/templates/crudkit/rows.html @@ -1,2 +1,2 @@ {% import "crudkit/_macros.html" as ui %} -{{ ui.rows(items, fields, getp=getp) }} +{{ ui.rows(items, fields, getp=getp, model=model) }} diff --git a/crudkit/html/ui_fragments.py b/crudkit/html/ui_fragments.py index a75cd59..c5de214 100644 --- a/crudkit/html/ui_fragments.py +++ b/crudkit/html/ui_fragments.py @@ -120,7 +120,7 @@ def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, na s = session(); svc = CrudService(s, default_eager_policy) rows, _ = svc.list(Model, spec) - html = render_template("crudkit/rows.html", items=rows, fields=fields, getp=_getp) + html = render_template("crudkit/rows.html", items=rows, fields=fields, getp=_getp, model=model) return html diff --git a/inventory/routes/worklog.py b/inventory/routes/worklog.py index 6e9d91f..8b5b66a 100644 --- a/inventory/routes/worklog.py +++ b/inventory/routes/worklog.py @@ -17,7 +17,7 @@ def list_worklog(): return render_template( 'table.html', header=worklog_headers, - model_name='worklog', + model_name='work_log', title="Work Log", fields = ['contact.identifier', 'work_item.identifier', 'start_time', 'end_time', 'complete', 'followup', 'analysis'], entry_route='worklog_item',