From 8a3eb2c2ef533d0ca72559815f522c9f25d6b105 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 30 Sep 2025 14:56:47 -0500 Subject: [PATCH] We are at the start of getting UPDATE support properly set up. --- inventory/routes/entry.py | 198 ++++++++++++++++++++++++- inventory/templates/base.html | 9 +- inventory/templates/submit_button.html | 5 +- 3 files changed, 194 insertions(+), 18 deletions(-) diff --git a/inventory/routes/entry.py b/inventory/routes/entry.py index 34b4fc5..929e803 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -1,10 +1,170 @@ +from datetime import datetime from flask import Blueprint, render_template, abort, request, jsonify, current_app +from typing import Any, Dict, List, Tuple, Callable, Optional import crudkit from crudkit.ui.fragments import render_form +ISO_DT_FORMATS = ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d") + bp_entry = Blueprint("entry", __name__) +ISO_DT_FORMATS = ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d") + +def _parse_dt_maybe(x: Any) -> Any: + if isinstance(x, datetime): + return x + if isinstance(x, str): + s = x.strip() + for fmt in ISO_DT_FORMATS: + try: + return datetime.strptime(s, fmt) + except ValueError: + pass + try: + return datetime.fromisoformat(s) + except Exception: + return x + return x + +def _normalize_for_compare(x: Any) -> Any: + if isinstance(x, (str, datetime)): + return _parse_dt_maybe(x) + return x + +def deep_diff( + old: Any, + new: Any, + *, + path: str = "", + ignore_keys: Optional[set] = None, + list_mode: str = "index", # "index" or "set" + custom_equal: Optional[Callable[[str, Any, Any], bool]] = None, +) -> Dict[str, Dict[str, Any]]: + """ + Returns {'added': {...}, 'removed': {...}, 'changed': {...}} + Paths use dot notation for dicts and [i] for lists. + """ + if ignore_keys is None: + ignore_keys = set() + + out: Dict[str, Dict[str, Any]] = {"added": {}, "removed": {}, "changed": {}} + + def mark_changed(p, a, b): + out["changed"][p] = {"from": a, "to": b} + + def rec(o, n, pfx): + # custom equality short-circuit + if custom_equal and custom_equal(pfx.rstrip("."), o, n): + return + + # Dict vs Dict + if isinstance(o, dict) and isinstance(n, dict): + o_keys = set(o.keys()) + n_keys = set(n.keys()) + + # removed + for k in sorted(o_keys - n_keys): + if k in ignore_keys: + continue + out["removed"][f"{pfx}{k}"] = o[k] + + # added + for k in sorted(n_keys - o_keys): + if k in ignore_keys: + continue + out["added"][f"{pfx}{k}"] = n[k] + + # present in both -> recurse + for k in sorted(o_keys & n_keys): + if k in ignore_keys: + continue + rec(o[k], n[k], f"{pfx}{k}.") + + return + + # List vs List + if isinstance(o, list) and isinstance(n, list): + if list_mode == "set": + if set(o) != set(n): + mark_changed(pfx.rstrip("."), o, n) + else: + max_len = max(len(o), len(n)) + for i in range(max_len): + key = f"{pfx}[{i}]" + if i >= len(o): + out["added"][key] = n[i] + elif i >= len(n): + out["removed"][key] = o[i] + else: + rec(o[i], n[i], f"{key}.") + return + + # Scalars or type mismatch + a = _normalize_for_compare(o) + b = _normalize_for_compare(n) + if a != b: + mark_changed(pfx.rstrip("."), o, n) + + rec(old, new, path) + return out + +def diff_to_patch(diff: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: + """ + Produce a shallow patch of changed/added top-level fields. + Onky includes leaf paths without dots/brackets; useful for simple UPDATEs. + """ + patch = {} + for k, v in diff["added"].items(): + if "." not in k and "[" not in k: + patch[k] = v + for k, v in diff["changed"].items(): + if "." not in k and "[" not in k: + patch[k] = v["to"] + return patch + +def normalize_payload(payload: dict, model): + """ + Take raw JSON dict from frontend and coerce valies + into types expected by the SQLAlchemy model. + """ + out = {} + for field, value in payload.items(): + if value == "" or value is None: + out[field] = None + continue + + # Look up the SQLAlchemy column type if available + col = getattr(model, field, None) + coltype = getattr(col, "type", None) + + if coltype is not None: + tname = coltype.__class__.__name__.lower() + + if "integer" in tname: + out[field] = int(value) + + elif "boolean" in tname: + # frontend may send true/false already, + # or string "true"/"false" + if isinstance(value, bool): + out[field] = value + else: + out[field] = str(value).lower() in ("1", "true", "yes", "on") + + elif "datetime" in tname: + out[field] = ( + value if isinstance(value, datetime) + else datetime.fromisoformat(value) + ) + + else: + out[field] = value + else: + out[field] = value + + return out + def init_entry_routes(app): @bp_entry.get("/entry//") @@ -65,7 +225,9 @@ def init_entry_routes(app): fields["fields"] = ["label", "first_name", "last_name", "title", "active", "staff", "location", "supervisor"] fields_spec = [ {"name": "label", "row": "label", "label": "", "type": "display", - "attrs": {"class": "display-6 mb-3"}}, + "attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}}, + {"name": "submit", "label": "", "row": "label", "type": "template", "template": "submit_button.html", + "wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}}, {"name": "last_name", "label": "Last Name", "label_attrs": {"class": "form-label"}, "attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}}, {"name": "first_name", "label": "First Name", "label_attrs": {"class": "form-label"}, @@ -85,7 +247,7 @@ def init_entry_routes(app): "row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}}, ] layout = [ - {"name": "label", "order": 0}, + {"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}}, {"name": "name", "order": 10, "attrs": {"class": "row"}}, {"name": "details", "order": 20, "attrs": {"class": "row mt-2"}}, {"name": "checkboxes", "order": 30, "parent": "details", @@ -98,7 +260,7 @@ def init_entry_routes(app): {"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}", "attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}}, {"name": "submit", "label": "", "row": "label", "type": "template", "template": "submit_button.html", - "wrap": {"class": "col text-end me-2"}, "attrs": {"data-model": model}}, + "wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}}, {"name": "contact", "row": "ownership", "wrap": {"class": "col"}, "label": "Contact", "label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}}, {"name": "work_item", "row": "ownership", "wrap": {"class": "col"}, "label": "Work Item", @@ -159,21 +321,41 @@ def init_entry_routes(app): try: if model not in ["inventory", "user", "worklog"]: raise TypeError("Invalid model.") - payload = request.get_json() cls = crudkit.crud.get_model(model) + payload = normalize_payload(request.get_json(), cls) + params = {} if model == "inventory": pass elif model == "user": - pass + params = { + "fields": [ + "last_name", + "first_name", + "title", + "supervisor_id", + "location_id", + "active", + "staff", + ] + } elif model == "worklog": - pass + params = { + "fields": [ + "contact_id", + "work_item_id", + "start_time", + "end_time", + "complete", + ] + } else: raise TypeError("Invalid model.") service = crudkit.crud.get_service(cls) - item = service.get(id) - print(item.as_dict(), payload) + item = service.get(id, params) + d = deep_diff(item.as_dict(), payload, ignore_keys={"id", "created_at", "updated_at"}) + print(f"OLD = {item.as_dict()}\n\nNEW = {payload}\n\nDIFF = {d}") return {"status": "success", "payload": payload} except Exception as e: diff --git a/inventory/templates/base.html b/inventory/templates/base.html index 8af3f31..df3df01 100644 --- a/inventory/templates/base.html +++ b/inventory/templates/base.html @@ -96,20 +96,13 @@ toastNumber = 0; - window.toastMessage = function (message, title, type = 'info') { + window.toastMessage = function (message, type = 'info') { const container = document.getElementById('toastContainer'); - const now = new Date(); - const timestamp = now.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }); const id = `toast${window.toastNumber++}`; const template = ` `; diff --git a/inventory/templates/submit_button.html b/inventory/templates/submit_button.html index 799ed72..c31081e 100644 --- a/inventory/templates/submit_button.html +++ b/inventory/templates/submit_button.html @@ -41,6 +41,7 @@ document.getElementById("{{ field['attrs']['data-model'] }}_form").addEventListener("submit", async e => { e.preventDefault(); const json = formToJson(e.target); + json['id'] = {{ field['template_ctx']['values']['id'] }}; response = await fetch("{{ url_for('entry.update_entry', id=field['template_ctx']['values']['id'], model=field['attrs']['data-model']) }}", { method: "POST", @@ -52,9 +53,9 @@ reply = await response.json(); if (reply['status'] === 'success') { - console.log("WELL DONE!") + toastMessage("This entry has been successfully saved!", "success"); } else { - console.log("YOU HAVE FAILED!") + toastMessage(`Unable to save entry: ${reply['error']}`, "danger"); } }); \ No newline at end of file