Redesign1 #1
5 changed files with 130 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}),
|
||||
|
|
|
|||
|
|
@ -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": <condition-dict>, "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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue