From 85b0e576c761f10dec4826497a6da2f0011a2a1c Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 2 Oct 2025 11:14:42 -0500 Subject: [PATCH 1/2] Early work for saving work_logs. --- inventory/routes/entry.py | 3 + inventory/templates/submit_button.html | 3 + inventory/templates/update_list.html | 193 ++++++++++++++----------- 3 files changed, 112 insertions(+), 87 deletions(-) diff --git a/inventory/routes/entry.py b/inventory/routes/entry.py index 363f361..f016f28 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -191,11 +191,14 @@ def init_entry_routes(app): "start_time", "end_time", "complete", + "updates", ] } else: raise TypeError("Invalid model.") + print(payload) + service = crudkit.crud.get_service(cls) service.update(id, data=payload, actor="update_entry") diff --git a/inventory/templates/submit_button.html b/inventory/templates/submit_button.html index c31081e..d50566e 100644 --- a/inventory/templates/submit_button.html +++ b/inventory/templates/submit_button.html @@ -1,5 +1,8 @@ -
-
- - {{ n.timestamp.strftime("%Y-%m-%d %H:%M") if n.timestamp else "" }} - -
-
- - -
-
- - - {% else %} -
  • No updates yet.
  • - {% endfor %} +
    +
    + + {{ n.timestamp.strftime("%Y-%m-%d %H:%M") if n.timestamp else "" }} + +
    +
    + + +
    +
    + + + {% else %} +
  • No updates yet.
  • + {% endfor %} + + From bcf14cf2517d4eef474d01d38e5bb59c6478856a Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 2 Oct 2025 14:38:02 -0500 Subject: [PATCH 2/2] Hooray! Updates to updates! --- crudkit/core/service.py | 28 +++++----- inventory/routes/entry.py | 37 +++++++++++-- inventory/templates/submit_button.html | 74 +++++++++++++++++++------- 3 files changed, 106 insertions(+), 33 deletions(-) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 366900a..fd8c953 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -649,15 +649,16 @@ class CRUDService(Generic[T]): return rows - def create(self, data: dict, actor=None) -> T: + def create(self, data: dict, actor=None, *, commit: bool = True) -> T: session = self.session obj = self.model(**data) session.add(obj) - session.commit() - self._log_version("create", obj, actor) + if commit: + session.commit() + self._log_version("create", obj, actor, commit=commit) return obj - def update(self, id: int, data: dict, actor=None) -> T: + def update(self, id: int, data: dict, actor=None, *, commit: bool = True) -> T: session = self.session obj = session.get(self.model, id) if not obj: @@ -695,7 +696,8 @@ class CRUDService(Generic[T]): return obj # Commit atomically - session.commit() + if commit: + session.commit() # AFTER snapshot for audit after = obj.as_dict() @@ -712,10 +714,10 @@ class CRUDService(Generic[T]): return obj # Log both what we *intended* and what *actually* happened - self._log_version("update", obj, actor, metadata={"diff": actual, "patch": patch}) + self._log_version("update", obj, actor, metadata={"diff": actual, "patch": patch}, commit=commit) return obj - def delete(self, id: int, hard: bool = False, actor = None): + def delete(self, id: int, hard: bool = False, actor = None, *, commit: bool = True): session = self.session obj = session.get(self.model, id) if not obj: @@ -725,11 +727,12 @@ class CRUDService(Generic[T]): else: soft = cast(_SoftDeletable, obj) soft.is_deleted = True - session.commit() - self._log_version("delete", obj, actor) + if commit: + session.commit() + self._log_version("delete", obj, actor, commit=commit) return obj - def _log_version(self, change_type: str, obj: T, actor=None, metadata: dict | None = None): + def _log_version(self, change_type: str, obj: T, actor=None, metadata: dict | None = None, *, commit: bool = True): session = self.session try: snapshot = {} @@ -747,7 +750,8 @@ class CRUDService(Generic[T]): meta=to_jsonable(metadata) if metadata else None, ) session.add(version) - session.commit() + if commit: + session.commit() except Exception as e: - log.warning(f"Version logging failed for {self.model.__name__} id={getattr(obj, "id", "?")}: {str(e)}") + log.warning(f"Version logging failed for {self.model.__name__} id={getattr(obj, 'id', '?')}: {str(e)}") session.rollback() diff --git a/inventory/routes/entry.py b/inventory/routes/entry.py index f016f28..aa8bad4 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -9,6 +9,28 @@ from crudkit.core import normalize_payload bp_entry = Blueprint("entry", __name__) +def _apply_worklog_updates(worklog, updates, delete_ids): + note_cls = type(worklog).updates.property.mapper.class_ + note_svc = crudkit.crud.get_service(note_cls) + sess = note_svc.session + + existing = {u.id: u for u in worklog.updates if not getattr(u, "is_deleted", False)} + + for item in updates: + uid = item.get("id") + content = (item.get("content") or "").strip() + if not content and not uid: + continue + if uid: + # per-note version log preserved, but commit deferred. + note_svc.update(uid, {"content": content}, actor="bulk_child_update", commit=False) + else: + note_svc.create({"work_log_id": worklog.id, "content": content}, actor="bulk_child_create", commit=False) + + for uid in delete_ids: + if uid in existing: + note_svc.delete(uid, actor="bulk_child_delete", commit=False) + def init_entry_routes(app): @bp_entry.get("/entry//") @@ -168,6 +190,9 @@ def init_entry_routes(app): cls = crudkit.crud.get_model(model) payload = normalize_payload(request.get_json(), cls) + updates = payload.pop("updates", None) or [] + delete_ids = payload.pop("delete_update_ids", None) or [] + params = {} if model == "inventory": pass @@ -197,13 +222,19 @@ def init_entry_routes(app): else: raise TypeError("Invalid model.") - print(payload) - service = crudkit.crud.get_service(cls) - service.update(id, data=payload, actor="update_entry") + sess = service.session + + obj = service.update(id, data=payload, actor="update_entry", commit=False) + + if model == "worklog" and (updates or delete_ids): + _apply_worklog_updates(obj, updates, delete_ids) + + sess.commit() return {"status": "success", "payload": payload} except Exception as e: + print(e) return {"status": "failure", "error": str(e)} app.register_blueprint(bp_entry) diff --git a/inventory/templates/submit_button.html b/inventory/templates/submit_button.html index d50566e..2d569b0 100644 --- a/inventory/templates/submit_button.html +++ b/inventory/templates/submit_button.html @@ -7,6 +7,7 @@ const fd = new FormData(form); const out = {}; + // base values fd.forEach((value, key) => { if (key in out) { if (!Array.isArray(out[key])) out[key] = [out[key]]; @@ -16,21 +17,21 @@ } }); + // normalize radios and checkboxes form.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(el => { if (!el.name) return; if (el.type === 'radio') { - if (out[el.name] !== undefined) return; + if (out[el.name] !== undefined) return; // already set for this group const checked = form.querySelector(`input[type="radio"][name="${CSS.escape(el.name)}"]:checked`); if (checked) out[el.name] = checked.value ?? true; + return; } if (el.type === 'checkbox') { const group = form.querySelectorAll(`input[type="checkbox"][name="${CSS.escape(el.name)}"]`); if (group.length > 1) { - const checkedVals = Array.from(group) - .filter(i => i.checked) - .map(i => i.value ?? true); + const checkedVals = Array.from(group).filter(i => i.checked).map(i => i.value ?? true); out[el.name] = checkedVals; } else { out[el.name] = el.checked; @@ -41,24 +42,61 @@ return out; } + function collectExistingUpdateIds() { + return Array.from(document.querySelectorAll('script[type="application/json"][id^="md-"]')) + .map(el => Number(el.id.slice(3))) + .filter(Number.isFinite); + } + + function collectEditedUpdates() { + const updates = []; + for (const id of collectExistingUpdateIds()) { + updates.push({ id, content: getMarkdown(id) }); // ensure getMarkdown exists + } + for (const md of (window.newDrafts || [])) { + if ((md ?? '').trim()) updates.push({ content: md }); + } + return updates; + } + + function collectDeletedIds() { + return (window.deletedIds || []).filter(Number.isFinite); + } + 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'] }}; + 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", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(json) - }); + // map friendly names to real FK columns if needed + if (json.contact && !json.contact_id) json.contact_id = Number(json.contact) || null; + if (json.work_item && !json.work_item_id) json.work_item_id = Number(json.work_item) || null; - reply = await response.json(); - if (reply['status'] === 'success') { - toastMessage("This entry has been successfully saved!", "success"); - } else { - toastMessage(`Unable to save entry: ${reply['error']}`, "danger"); + // child mutations + json.updates = collectEditedUpdates(); + json.delete_update_ids = collectDeletedIds(); + + try { + const response = await fetch( + "{{ url_for('entry.update_entry', id=field['template_ctx']['values']['id'], model=field['attrs']['data-model']) }}", + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(json), + } + ); + + const reply = await response.json(); + if (reply.status === 'success') { + toastMessage('This entry has been successfully saved!', 'success'); + window.newDrafts = []; + window.deletedIds = []; + } else { + toastMessage(`Unable to save entry: ${reply.error}`, 'danger'); + } + } catch (err) { + toastMessage(`Network error: ${String(err)}`, 'danger'); } - }); + }); \ No newline at end of file