We are at the start of getting UPDATE support properly set up.
This commit is contained in:
parent
dbf0d6169a
commit
8a3eb2c2ef
3 changed files with 194 additions and 18 deletions
|
|
@ -1,10 +1,170 @@
|
||||||
|
from datetime import datetime
|
||||||
from flask import Blueprint, render_template, abort, request, jsonify, current_app
|
from flask import Blueprint, render_template, abort, request, jsonify, current_app
|
||||||
|
from typing import Any, Dict, List, Tuple, Callable, Optional
|
||||||
|
|
||||||
import crudkit
|
import crudkit
|
||||||
from crudkit.ui.fragments import render_form
|
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__)
|
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):
|
def init_entry_routes(app):
|
||||||
|
|
||||||
@bp_entry.get("/entry/<model>/<int:id>")
|
@bp_entry.get("/entry/<model>/<int:id>")
|
||||||
|
|
@ -65,7 +225,9 @@ def init_entry_routes(app):
|
||||||
fields["fields"] = ["label", "first_name", "last_name", "title", "active", "staff", "location", "supervisor"]
|
fields["fields"] = ["label", "first_name", "last_name", "title", "active", "staff", "location", "supervisor"]
|
||||||
fields_spec = [
|
fields_spec = [
|
||||||
{"name": "label", "row": "label", "label": "", "type": "display",
|
{"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"},
|
{"name": "last_name", "label": "Last Name", "label_attrs": {"class": "form-label"},
|
||||||
"attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
|
"attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
|
||||||
{"name": "first_name", "label": "First Name", "label_attrs": {"class": "form-label"},
|
{"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"}},
|
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
|
||||||
]
|
]
|
||||||
layout = [
|
layout = [
|
||||||
{"name": "label", "order": 0},
|
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
|
||||||
{"name": "name", "order": 10, "attrs": {"class": "row"}},
|
{"name": "name", "order": 10, "attrs": {"class": "row"}},
|
||||||
{"name": "details", "order": 20, "attrs": {"class": "row mt-2"}},
|
{"name": "details", "order": 20, "attrs": {"class": "row mt-2"}},
|
||||||
{"name": "checkboxes", "order": 30, "parent": "details",
|
{"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}",
|
{"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}",
|
||||||
"attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}},
|
"attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}},
|
||||||
{"name": "submit", "label": "", "row": "label", "type": "template", "template": "submit_button.html",
|
{"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",
|
{"name": "contact", "row": "ownership", "wrap": {"class": "col"}, "label": "Contact",
|
||||||
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
|
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
|
||||||
{"name": "work_item", "row": "ownership", "wrap": {"class": "col"}, "label": "Work Item",
|
{"name": "work_item", "row": "ownership", "wrap": {"class": "col"}, "label": "Work Item",
|
||||||
|
|
@ -159,21 +321,41 @@ def init_entry_routes(app):
|
||||||
try:
|
try:
|
||||||
if model not in ["inventory", "user", "worklog"]:
|
if model not in ["inventory", "user", "worklog"]:
|
||||||
raise TypeError("Invalid model.")
|
raise TypeError("Invalid model.")
|
||||||
payload = request.get_json()
|
|
||||||
cls = crudkit.crud.get_model(model)
|
cls = crudkit.crud.get_model(model)
|
||||||
|
payload = normalize_payload(request.get_json(), cls)
|
||||||
|
|
||||||
|
params = {}
|
||||||
if model == "inventory":
|
if model == "inventory":
|
||||||
pass
|
pass
|
||||||
elif model == "user":
|
elif model == "user":
|
||||||
pass
|
params = {
|
||||||
|
"fields": [
|
||||||
|
"last_name",
|
||||||
|
"first_name",
|
||||||
|
"title",
|
||||||
|
"supervisor_id",
|
||||||
|
"location_id",
|
||||||
|
"active",
|
||||||
|
"staff",
|
||||||
|
]
|
||||||
|
}
|
||||||
elif model == "worklog":
|
elif model == "worklog":
|
||||||
pass
|
params = {
|
||||||
|
"fields": [
|
||||||
|
"contact_id",
|
||||||
|
"work_item_id",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"complete",
|
||||||
|
]
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
raise TypeError("Invalid model.")
|
raise TypeError("Invalid model.")
|
||||||
|
|
||||||
service = crudkit.crud.get_service(cls)
|
service = crudkit.crud.get_service(cls)
|
||||||
item = service.get(id)
|
item = service.get(id, params)
|
||||||
print(item.as_dict(), payload)
|
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}
|
return {"status": "success", "payload": payload}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -96,20 +96,13 @@
|
||||||
|
|
||||||
toastNumber = 0;
|
toastNumber = 0;
|
||||||
|
|
||||||
window.toastMessage = function (message, title, type = 'info') {
|
window.toastMessage = function (message, type = 'info') {
|
||||||
const container = document.getElementById('toastContainer');
|
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 id = `toast${window.toastNumber++}`;
|
||||||
|
|
||||||
const template = `
|
const template = `
|
||||||
<div class="toast text-bg-${type}" id="${id}" role="alert" aria-live="assertive" aria-atomic="true">
|
<div class="toast text-bg-${type}" id="${id}" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
<div class="toast-header">
|
|
||||||
<strong class="me-auto">${title}</strong>
|
|
||||||
<small class="text-body-secondary">${timestamp}</small>
|
|
||||||
<button type="button" class="btn-close ms-2 mb-1" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="toast-body">${message}</div>
|
<div class="toast-body">${message}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
document.getElementById("{{ field['attrs']['data-model'] }}_form").addEventListener("submit", async e => {
|
document.getElementById("{{ field['attrs']['data-model'] }}_form").addEventListener("submit", async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const json = formToJson(e.target);
|
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']) }}", {
|
response = await fetch("{{ url_for('entry.update_entry', id=field['template_ctx']['values']['id'], model=field['attrs']['data-model']) }}", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -52,9 +53,9 @@
|
||||||
|
|
||||||
reply = await response.json();
|
reply = await response.json();
|
||||||
if (reply['status'] === 'success') {
|
if (reply['status'] === 'success') {
|
||||||
console.log("WELL DONE!")
|
toastMessage("This entry has been successfully saved!", "success");
|
||||||
} else {
|
} else {
|
||||||
console.log("YOU HAVE FAILED!")
|
toastMessage(`Unable to save entry: ${reply['error']}`, "danger");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue