diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 8e99f80..5766f8f 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -10,7 +10,7 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.sql import operators 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.spec import CRUDSpec from crudkit.core.types import OrderSpec, SeekWindow @@ -661,41 +661,58 @@ class CRUDService(Generic[T]): session = self.session obj = session.get(self.model, id) 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() - # Apply patch - for k, v in data.items(): - setattr(obj, k, v) + # Normalize and restrict payload to real columns + norm = normalize_payload(data, self.model) + incoming = filter_to_columns(norm, self.model) - # If nothing changed at ORM level, bail early - if not session.is_modified(obj, include_collections=False): + # Build a synthetic "desired" state for top-level columns + 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 + # 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() + # AFTER snapshot for audit after = obj.as_dict() - diff = deep_diff( - before, - after, - ignore_keys={"updated_at", "created_at", "id"}, + # Actual diff (captures triggers/defaults, still ignoring noisy keys) + actual = deep_diff( + before, after, + ignore_keys={"id", "created_at", "updated_at"}, list_mode="index", ) - # If somehow no diff, do not spam versions - if not(diff["added"] or diff["removed"] or diff["changed"]): + # If truly nothing changed post-commit (rare), skip version spam + if not (actual["added"] or actual["removed"] or actual["changed"]): 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 def delete(self, id: int, hard: bool = False, actor = None): diff --git a/inventory/routes/entry.py b/inventory/routes/entry.py index 591e42f..363f361 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -197,7 +197,7 @@ def init_entry_routes(app): raise TypeError("Invalid model.") service = crudkit.crud.get_service(cls) - item = service.get(id, params) + service.update(id, data=payload, actor="update_entry") return {"status": "success", "payload": payload} except Exception as e: diff --git a/inventory/routes/index.py b/inventory/routes/index.py index fc4191b..794c5ad 100644 --- a/inventory/routes/index.py +++ b/inventory/routes/index.py @@ -31,33 +31,35 @@ def init_index_routes(app): "limit": 0 }) rows = [item.as_dict() for item in inventory_report_rows] - df = pd.DataFrame(rows) + chart_data = {} + if 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) - top_labels = ( - xtab.sum(axis=0) - .sort_values(ascending=False) - .index.tolist() - ) - xtab = xtab[top_labels] + top_labels = ( + xtab.sum(axis=0) + .sort_values(ascending=False) + .index.tolist() + ) + xtab = xtab[top_labels] - preferred_order = [ - "Deployed", "Working", "Unverified", - "Partially Inoperable", "Inoperable", - "Removed", "Disposed" - ] - conditions = [c for c in preferred_order if c in xtab.index] + [c for c in xtab.index if c not in preferred_order] - xtab = xtab.loc[conditions] + preferred_order = [ + "Deployed", "Working", "Unverified", + "Partially Inoperable", "Inoperable", + "Removed", "Disposed" + ] + conditions = [c for c in preferred_order if c in xtab.index] + [c for c in xtab.index if c not in preferred_order] + xtab = xtab.loc[conditions] - chart_data = { - "labels": top_labels, - "datasets": [{ - "label": cond, - "data": xtab.loc[cond].to_list() + chart_data = { + "labels": top_labels, + "datasets": [{ + "label": cond, + "data": xtab.loc[cond].to_list() + } + for cond in xtab.index] } - for cond in xtab.index] - } columns = [ {"field": "start_time", "label": "Start", "format": "date"},