Compare commits
No commits in common. "515eb27fe07de9711390df3b679d01403451ebf4" and "a3f2c794f5c7a5505c4407398b39967024db278e" have entirely different histories.
515eb27fe0
...
a3f2c794f5
8 changed files with 6 additions and 419 deletions
|
|
@ -1,21 +1,8 @@
|
||||||
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect
|
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func
|
||||||
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE
|
from sqlalchemy.orm import declarative_mixin, declarative_base
|
||||||
|
|
||||||
Base = 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
|
@declarative_mixin
|
||||||
class CRUDMixin:
|
class CRUDMixin:
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ from sqlalchemy import and_, func, inspect, or_, text
|
||||||
from sqlalchemy.engine import Engine, Connection
|
from sqlalchemy.engine import Engine, Connection
|
||||||
from sqlalchemy.orm import Load, Session, raiseload, selectinload, with_polymorphic, Mapper, RelationshipProperty, class_mapper, ColumnProperty
|
from sqlalchemy.orm import Load, Session, raiseload, selectinload, with_polymorphic, Mapper, RelationshipProperty, class_mapper, ColumnProperty
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
from sqlalchemy.orm.base import NO_VALUE
|
|
||||||
from sqlalchemy.orm.util import AliasedClass
|
from sqlalchemy.orm.util import AliasedClass
|
||||||
from sqlalchemy.sql import operators
|
from sqlalchemy.sql import operators
|
||||||
from sqlalchemy.sql.elements import UnaryExpression
|
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.core.types import OrderSpec, SeekWindow
|
||||||
from crudkit.backend import BackendInfo, make_backend_info
|
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:
|
def _is_rel(model_cls, name: str) -> bool:
|
||||||
try:
|
try:
|
||||||
prop = model_cls.__mapper__.relationships.get(name)
|
prop = model_cls.__mapper__.relationships.get(name)
|
||||||
|
|
@ -266,10 +232,7 @@ class CRUDService(Generic[T]):
|
||||||
- forward/backward seek via `key` and `backward`
|
- forward/backward seek via `key` and `backward`
|
||||||
Returns a SeekWindow with items, first/last keys, order spec, limit, and optional total.
|
Returns a SeekWindow with items, first/last keys, order spec, limit, and optional total.
|
||||||
"""
|
"""
|
||||||
fields = list(params.get("fields", []))
|
session = self.session
|
||||||
if fields:
|
|
||||||
fields = _expand_requires(self.model, fields)
|
|
||||||
params = {**params, "fields": fields}
|
|
||||||
query, root_alias = self.get_query()
|
query, root_alias = self.get_query()
|
||||||
|
|
||||||
spec = CRUDSpec(self.model, params or {}, root_alias)
|
spec = CRUDSpec(self.model, params or {}, root_alias)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
"<child_rel>": { ... }
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
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(<rel>)[.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
|
|
||||||
|
|
@ -19,52 +19,6 @@ _ALLOWED_ATTRS = {
|
||||||
"id", "name", "value",
|
"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():
|
def get_env():
|
||||||
app = current_app
|
app = current_app
|
||||||
default_path = os.path.join(os.path.dirname(__file__), 'templates')
|
default_path = os.path.join(os.path.dirname(__file__), 'templates')
|
||||||
|
|
@ -74,79 +28,6 @@ def get_env():
|
||||||
loader=ChoiceLoader([app.jinja_loader, fallback_loader])
|
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:
|
def _is_column_attr(attr) -> bool:
|
||||||
try:
|
try:
|
||||||
return isinstance(attr, InstrumentedAttribute) and isinstance(attr.property, ColumnProperty)
|
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):
|
elif isinstance(v, str):
|
||||||
if len(v) > 512:
|
if len(v) > 512:
|
||||||
v = 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 k.startswith("data-") or k.startswith("aria-") or k in _ALLOWED_ATTRS:
|
||||||
if isinstance(v, bool):
|
if isinstance(v, bool):
|
||||||
if v:
|
if v:
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class User(Base, CRUDMixin):
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
__crud_label__ = "{label}"
|
__crud_label__ = "{label}"
|
||||||
__crudkit_field_requires__ = {
|
__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)
|
first_name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True, index=True)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ def init_entry_routes(app):
|
||||||
"wrap": {"class": "h-100 w-100"}},
|
"wrap": {"class": "h-100 w-100"}},
|
||||||
|
|
||||||
{"name": "notes", "type": "textarea", "label": "Notes", "row": "notes", "wrap": {"class": "col"},
|
{"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 = [
|
layout = [
|
||||||
{"name": "label", "order": 5},
|
{"name": "label", "order": 5},
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ bp_listing = Blueprint("listing", __name__)
|
||||||
def init_listing_routes(app):
|
def init_listing_routes(app):
|
||||||
@bp_listing.get("/listing/<model>")
|
@bp_listing.get("/listing/<model>")
|
||||||
def show_list(model):
|
def show_list(model):
|
||||||
|
page_num = int(request.args.get("page", 1))
|
||||||
if model.lower() not in {"inventory", "user", "worklog"}:
|
if model.lower() not in {"inventory", "user", "worklog"}:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,6 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% block header %}
|
{% block header %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<div class="d-flex">
|
|
||||||
<input type="text" id="search" class="form-control me-3">
|
|
||||||
<button type="button" class="btn btn-primary" disabled>Search</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue