diff --git a/crudkit/__init__.py b/crudkit/__init__.py index 3959538..e6af0be 100644 --- a/crudkit/__init__.py +++ b/crudkit/__init__.py @@ -1,8 +1,17 @@ from .backend import BackendInfo, make_backend_info from .config import Config, DevConfig, TestConfig, ProdConfig, get_config, build_database_url from .engines import CRUDKitRuntime, build_engine, build_sessionmaker +from .integration import CRUDKit __all__ = [ "Config", "DevConfig", "TestConfig", "ProdConfig", "get_config", "build_database_url", "CRUDKitRuntime", "build_engine", "build_sessionmaker", "BackendInfo", "make_backend_info" ] + +runtime = CRUDKitRuntime() +crud: CRUDKit | None = None + +def init_crud(app): + global crud + crud = CRUDKit(app, runtime) + return crud diff --git a/crudkit/config.py b/crudkit/config.py index a19d4d3..0439a3e 100644 --- a/crudkit/config.py +++ b/crudkit/config.py @@ -123,6 +123,7 @@ def build_database_url( "Trusted_Connection": "yes", "Encrypt": "yes", "TrustServerCertificate": "yes", + "MARS_Connection": "yes", } base_opts.update(options) qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in base_opts.items()) @@ -135,6 +136,7 @@ def build_database_url( "driver": driver, "Encrypt": "yes", "TrustServerCertificate": "yes", + "MARS_Connection": "yes", } base_opts.update(options) qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in base_opts.items()) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index ed0063a..6c0afd6 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,4 +1,4 @@ -from typing import Any, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast +from typing import Any, Callable, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic, Mapper from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.util import AliasedClass @@ -37,13 +37,13 @@ class CRUDService(Generic[T]): def __init__( self, model: Type[T], - session: Session, + session_factory: Callable[[], Session], polymorphic: bool = False, *, backend: Optional[BackendInfo] = None ): self.model = model - self.session = session + self._session_factory = session_factory self.polymorphic = polymorphic self.supports_soft_delete = hasattr(model, 'is_deleted') # Cache backend info once. If not provided, derive from session bind. @@ -51,6 +51,10 @@ class CRUDService(Generic[T]): eng: Engine = bind.engine if isinstance(bind, Connection) else cast(Engine, bind) self.backend = backend or make_backend_info(eng) + @property + def session(self) -> Session: + return self._session_factory() + def get_query(self): if self.polymorphic: poly = with_polymorphic(self.model, "*") @@ -69,7 +73,6 @@ class CRUDService(Generic[T]): return cols or [text("1")] def get(self, id: int, params=None) -> T | None: - print(f"I AM GETTING A THING! A THINGS! {params}") query, root_alias = self.get_query() include_deleted = False diff --git a/crudkit/registry.py b/crudkit/registry.py index 9c66153..fe3bfb7 100644 --- a/crudkit/registry.py +++ b/crudkit/registry.py @@ -71,10 +71,10 @@ class CRUDRegistry: SessionMaker = self._rt.session_factory if SessionMaker is None: raise RuntimeError("CRUDKitRuntime.session_factory is not initialized.") - session: Session = SessionMaker() + svc = CRUDService( model, - session=session, + session_factory=SessionMaker, polymorphic=polymorphic, backend=self._rt.backend, **(service_kwargs or {}), diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index 4fe4208..5c10f9d 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -4,6 +4,7 @@ from flask import current_app, url_for from jinja2 import Environment, FileSystemLoader, ChoiceLoader from sqlalchemy import inspect from sqlalchemy.orm import class_mapper, RelationshipProperty +from sqlalchemy.orm.attributes import NO_VALUE from typing import Any, Dict, List, Optional, Tuple def get_env(): @@ -15,10 +16,88 @@ def get_env(): loader=ChoiceLoader([app.jinja_loader, fallback_loader]) ) +def _val_from_row_or_obj(row: Dict[str, Any], obj: Any, dotted: str) -> Any: + """Best-effort deep get: try the projected row first, then the ORM object.""" + val = _deep_get(row, dotted) + if val is None: + val = _deep_get_from_obj(obj, dotted) + return val + +def _matches_simple_condition(row: Dict[str, Any], obj: Any, cond: Dict[str, Any]) -> bool: + """ + Supports: + {"field": "foo.bar", "eq": 10} + {"field": "foo", "ne": None} + {"field": "count", "gt": 0} (also lt, gte, lte) + {"field": "name", "in": ["a","b"]} + {"field": "thing", "is": None, | True | False} + {"any": [ ...subconds... ]} # OR + {"all": [ ...subconds... ]} # AND + {"not": { ...subcond... }} # NOT + """ + if "any" in cond: + return any(_matches_simple_condition(row, obj, c) for c in cond["any"]) + if "all" in cond: + return all(_matches_simple_condition(row, obj, c) for c in cond["all"]) + if "not" in cond: + return not _matches_simple_condition(row, obj, cond["not"]) + + field = cond.get("field") + val = _val_from_row_or_obj(row, obj, field) if field else None + + if "is" in cond: + target = cond["is"] + if target is None: + return val is None + if isinstance(target, bool): + return bool(val) is target + return val is target + + if "eq" in cond: + return val == cond["eq"] + if "ne" in cond: + return val != cond["ne"] + if "gt" in cond: + try: return val > cond["gt"] + except Exception: return False + if "lt" in cond: + try: return val < cond["lt"] + except Exception: return False + if "gte" in cond: + try: return val >= cond["gte"] + except Exception: return False + if "lte" in cond: + try: return val <= cond["lte"] + except Exception: return False + if "in" in cond: + try: return val in cond["in"] + except Exception: return False + + return False + +def _row_class_for(row: Dict[str, Any], obj: Any, rules: Optional[List[Dict[str, Any]]]) -> Optional[str]: + """ + rules is a list of: + {"when": , "class": "table-warning fw-semibold"} + Multiple matching rules stack classes. Later wins on duplicates by normal CSS rules. + """ + if not rules: + return None + classes = [] + for rule in rules: + when = rule.get("when") or {} + if _matches_simple_condition(row, obj, when): + cls = rule.get("class") + if cls: + classes.append(cls) + + return " ".join(dict.fromkeys(classes)) or None + def _is_rel_loaded(obj, rel_name: str) -> bool: try: state = inspect(obj) - return state.attrs[rel_name].loaded_value is not None + attr = state.attrs[rel_name] + return attr.loaded_value is not NO_VALUE except Exception: return False @@ -27,7 +106,6 @@ def _deep_get_from_obj(obj, dotted: str): parts = dotted.split(".") for i, part in enumerate(parts): if i < len(parts) - 1 and not _is_rel_loaded(cur, part): - print(f"WARNING: {cur}.{part} is not loaded!") return None cur = getattr(cur, part, None) if cur is None: @@ -82,12 +160,10 @@ def _build_href(spec: Dict[str, Any], row: Dict[str, Any], obj) -> Optional[str] else: params[k] = v if any(v is None for v in params.values()): - print(f"[render_table] url_for failed: endpoint={spec}: params={params}") return None try: return url_for('crudkit.' + spec["endpoint"], **params) except Exception as e: - print(f"[render_table] url_for failed: endpoint={spec['endpoint']} params={params} err={e}") return None def _humanize(field: str) -> str: @@ -104,6 +180,28 @@ def _normalize_columns(columns: Optional[List[Dict[str, Any]]], default_fields: norm.append(c) return norm +def _normalize_opts(opts: Dict[str, Any]) -> Dict[str, Any]: + """ + Accept either: + render_table(..., object_class='user', row_classe[...]) + or: + render_table(..., opts={'object_class': 'user', 'row_classes': [...]}) + + Returns a flat dict with top-level keys for convenience, while preserving + all original keys for the template. + """ + if not isinstance(opts, dict): + return {} + + flat = dict(opts) + + nested = flat.get("opts") + if isinstance(nested, dict): + for k, v in nested.items(): + flat.setdefault(k, v) + + return flat + def get_crudkit_template(env, name): try: return env.get_template(f'crudkit/{name}') @@ -128,12 +226,16 @@ def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = N if not objects: return template.render(fields=[], rows=[]) + flat_opts = _normalize_opts(opts) + proj = getattr(objects[0], "__crudkit_projection__", None) row_dicts = [obj.as_dict(proj) for obj in objects] default_fields = [k for k in row_dicts[0].keys() if k != "id"] cols = _normalize_columns(columns, default_fields) + row_rules = (flat_opts.get("row_classes") or []) + disp_rows = [] for obj, rd in zip(objects, row_dicts): cells = [] @@ -144,9 +246,11 @@ def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = N href = _build_href(col.get("link"), rd, obj) if col.get("link") else None cls = _class_for(raw, col.get("classes")) cells.append({"text": text, "href": href, "class": cls}) - disp_rows.append({"id": rd.get("id"), "cells": cells}) - return template.render(columns=cols, rows=disp_rows, kwargs=opts) + row_cls = _row_class_for(rd, obj, row_rules) + disp_rows.append({"id": rd.get("id"), "class": row_cls, "cells": cells}) + + return template.render(columns=cols, rows=disp_rows, kwargs=flat_opts) def render_form(model_cls, values, session=None): env = get_env()