Updates to patching logic.
This commit is contained in:
parent
c040ff74c9
commit
244f0945bb
3 changed files with 63 additions and 44 deletions
|
|
@ -10,7 +10,7 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
from sqlalchemy.sql import operators
|
from sqlalchemy.sql import operators
|
||||||
from sqlalchemy.sql.elements import UnaryExpression, ColumnElement
|
from sqlalchemy.sql.elements import UnaryExpression, ColumnElement
|
||||||
|
|
||||||
from crudkit.core import deep_diff, diff_to_patch
|
from crudkit.core import deep_diff, diff_to_patch, filter_to_columns, normalize_payload
|
||||||
from crudkit.core.base import Version
|
from crudkit.core.base import Version
|
||||||
from crudkit.core.spec import CRUDSpec
|
from crudkit.core.spec import CRUDSpec
|
||||||
from crudkit.core.types import OrderSpec, SeekWindow
|
from crudkit.core.types import OrderSpec, SeekWindow
|
||||||
|
|
@ -661,41 +661,58 @@ class CRUDService(Generic[T]):
|
||||||
session = self.session
|
session = self.session
|
||||||
obj = session.get(self.model, id)
|
obj = session.get(self.model, id)
|
||||||
if not obj:
|
if not obj:
|
||||||
raise ValueError("f{self.model.__name__} id ID {id} not found.")
|
raise ValueError(f"{self.model.__name__} with ID {id} not found.")
|
||||||
|
|
||||||
# Only touch real columns
|
|
||||||
valid = {c.name for c in self.model.__table__.columns}
|
|
||||||
unknown = set(data) - valid
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"Unknown fields: {', '.join(sorted(unknown))}")
|
|
||||||
|
|
||||||
# BEFORE snapshot (non-lazy, columns-only is fine)
|
|
||||||
before = obj.as_dict()
|
before = obj.as_dict()
|
||||||
|
|
||||||
# Apply patch
|
# Normalize and restrict payload to real columns
|
||||||
for k, v in data.items():
|
norm = normalize_payload(data, self.model)
|
||||||
setattr(obj, k, v)
|
incoming = filter_to_columns(norm, self.model)
|
||||||
|
|
||||||
# If nothing changed at ORM level, bail early
|
# Build a synthetic "desired" state for top-level columns
|
||||||
if not session.is_modified(obj, include_collections=False):
|
desired = {**before, **incoming}
|
||||||
|
|
||||||
|
# Compute intended change set (before vs intended)
|
||||||
|
proposed = deep_diff(
|
||||||
|
before, desired,
|
||||||
|
ignore_keys={"id", "created_at", "updated_at"},
|
||||||
|
list_mode="index",
|
||||||
|
)
|
||||||
|
patch = diff_to_patch(proposed)
|
||||||
|
|
||||||
|
# Nothing to do
|
||||||
|
if not patch:
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
# Apply only what actually changes
|
||||||
|
for k, v in patch.items():
|
||||||
|
setattr(obj, k, v)
|
||||||
|
|
||||||
|
# Optional: skip commit if ORM says no real change (paranoid check)
|
||||||
|
# Note: is_modified can lie if attrs are expired; use history for certainty.
|
||||||
|
dirty = any(inspect(obj).attrs[k].history.has_changes() for k in patch.keys())
|
||||||
|
if not dirty:
|
||||||
|
return obj
|
||||||
|
|
||||||
|
# Commit atomically
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
# AFTER snapshot for audit
|
||||||
after = obj.as_dict()
|
after = obj.as_dict()
|
||||||
|
|
||||||
diff = deep_diff(
|
# Actual diff (captures triggers/defaults, still ignoring noisy keys)
|
||||||
before,
|
actual = deep_diff(
|
||||||
after,
|
before, after,
|
||||||
ignore_keys={"updated_at", "created_at", "id"},
|
ignore_keys={"id", "created_at", "updated_at"},
|
||||||
list_mode="index",
|
list_mode="index",
|
||||||
)
|
)
|
||||||
|
|
||||||
# If somehow no diff, do not spam versions
|
# If truly nothing changed post-commit (rare), skip version spam
|
||||||
if not(diff["added"] or diff["removed"] or diff["changed"]):
|
if not (actual["added"] or actual["removed"] or actual["changed"]):
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
self._log_version("update", obj, actor, metadata={"diff": diff})
|
# Log both what we *intended* and what *actually* happened
|
||||||
|
self._log_version("update", obj, actor, metadata={"diff": actual, "patch": patch})
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def delete(self, id: int, hard: bool = False, actor = None):
|
def delete(self, id: int, hard: bool = False, actor = None):
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ def init_entry_routes(app):
|
||||||
raise TypeError("Invalid model.")
|
raise TypeError("Invalid model.")
|
||||||
|
|
||||||
service = crudkit.crud.get_service(cls)
|
service = crudkit.crud.get_service(cls)
|
||||||
item = service.get(id, params)
|
service.update(id, data=payload, actor="update_entry")
|
||||||
|
|
||||||
return {"status": "success", "payload": payload}
|
return {"status": "success", "payload": payload}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ def init_index_routes(app):
|
||||||
"limit": 0
|
"limit": 0
|
||||||
})
|
})
|
||||||
rows = [item.as_dict() for item in inventory_report_rows]
|
rows = [item.as_dict() for item in inventory_report_rows]
|
||||||
|
chart_data = {}
|
||||||
|
if rows:
|
||||||
df = pd.DataFrame(rows)
|
df = pd.DataFrame(rows)
|
||||||
|
|
||||||
xtab = pd.crosstab(df["condition"], df["device_type.description"]).astype(int)
|
xtab = pd.crosstab(df["condition"], df["device_type.description"]).astype(int)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue