Entry creation!

This commit is contained in:
Yaro Kasear 2025-10-03 09:02:15 -05:00
parent 53efc8551d
commit 2502375d32
6 changed files with 252 additions and 178 deletions

View file

@ -1,5 +1,4 @@
{# show label unless hidden/custom #} {# show label unless hidden/custom #}
<!-- {{ field_name }} (field) -->
{% if field_type != 'hidden' and field_label %} {% if field_type != 'hidden' and field_label %}
<label for="{{ field_name }}" <label for="{{ field_name }}"
{% if label_attrs %}{% for k,v in label_attrs.items() %} {% if label_attrs %}{% for k,v in label_attrs.items() %}

View file

@ -1,6 +1,5 @@
<form method="POST" id="{{ model_name|lower }}_form"> <form method="POST" id="{{ model_name|lower }}_form">
{% macro render_row(row) %} {% macro render_row(row) %}
<!-- {{ row.name }} (row) -->
{% if row.fields or row.children or row.legend %} {% if row.fields or row.children or row.legend %}
{% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %} {% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %}
<fieldset <fieldset

View file

@ -9,42 +9,13 @@ from crudkit.core import normalize_payload
bp_entry = Blueprint("entry", __name__) bp_entry = Blueprint("entry", __name__)
def _apply_worklog_updates(worklog, updates, delete_ids): def _fields_for_model(model: str):
note_cls = type(worklog).updates.property.mapper.class_ fields: list[str] = []
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/<model>/<int:id>")
def entry(model: str, id: int):
cls = crudkit.crud.get_model(model)
if cls is None or model not in ["inventory", "worklog", "user"]:
abort(404)
fields = {}
fields_spec = [] fields_spec = []
layout = [] layout = []
if model == "inventory": if model == "inventory":
fields["fields"] = ["label", "name", "serial", "barcode", "brand", "model", fields = ["label", "name", "serial", "barcode", "brand", "model",
"device_type", "owner", "location", "condition", "image", "notes"] "device_type", "owner", "location", "condition", "image", "notes"]
fields_spec = [ fields_spec = [
{"name": "label", "type": "display", "label": "", "row": "label", {"name": "label", "type": "display", "label": "", "row": "label",
@ -89,7 +60,7 @@ def init_entry_routes(app):
] ]
elif model.lower() == 'user': elif model.lower() == 'user':
fields["fields"] = ["label", "first_name", "last_name", "title", "active", "staff", "location", "supervisor"] fields = []
fields_spec = [ fields_spec = [
{"name": "label", "row": "label", "label": "", "type": "display", {"name": "label", "row": "label", "label": "", "type": "display",
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}}, "attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}},
@ -122,7 +93,19 @@ def init_entry_routes(app):
] ]
elif model == "worklog": elif model == "worklog":
fields["fields"] = ["id", "contact", "work_item", "start_time", "end_time", "complete"] # tell the service to eager-load precisely what the template needs
fields = [
"id",
"contact.label",
"work_item.label",
"start_time",
"end_time",
"complete",
"updates.id",
"updates.content",
"updates.timestamp",
"updates.is_deleted",
]
fields_spec = [ fields_spec = [
{"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"}},
@ -148,41 +131,141 @@ def init_entry_routes(app):
{"name": "updates", "order": 30, "attrs": {"class": "row"}}, {"name": "updates", "order": 30, "attrs": {"class": "row"}},
] ]
return (fields, fields_spec, layout)
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/<model>/<int:id>")
def entry(model: str, id: int):
cls = crudkit.crud.get_model(model)
if cls is None or model not in ["inventory", "worklog", "user"]:
abort(404)
fields, fields_spec, layout = _fields_for_model(model)
svc = crudkit.crud.get_service(cls) svc = crudkit.crud.get_service(cls)
obj = svc.get(id, fields) params = {"fields": fields} if fields else {}
obj = svc.get(id, params)
if obj is None: if obj is None:
abort(404) abort(404)
# Use the scoped_session proxy so teardown .remove() cleans it up
ScopedSession = current_app.extensions["crudkit"]["Session"] ScopedSession = current_app.extensions["crudkit"]["Session"]
if model == "worklog":
updates_cls = type(obj).updates.property.mapper.class_
updates_q = (ScopedSession.query(updates_cls)
.filter(updates_cls.work_log_id == obj.id,
updates_cls.is_deleted == False)
.order_by(updates_cls.timestamp.asc()))
all_updates = updates_q.all()
for f in fields_spec:
if f.get("name") == "updates" and f.get("type") == "template":
ctx = dict(f.get("template_ctx") or {})
ctx["updates"] = all_updates
f["template_ctx"] = ctx
break
form = render_form( form = render_form(
cls, cls,
obj.as_dict(), obj.as_dict(),
session=ScopedSession, # ← fixed: pass scoped proxy, not svc.session session=ScopedSession,
instance=obj,
fields_spec=fields_spec,
layout=layout,
submit_attrs={"class": "d-none", "disable": True}
)
return render_template("entry.html", form=form)
@bp_entry.get("/entry/<model>/new")
def entry_new(model: str):
cls = crudkit.crud.get_model(model)
if cls is None or model not in ["inventory", "worklog", "user"]:
abort(404)
fields, fields_spec, layout = _fields_for_model(model)
if model == "worklog":
for f in fields_spec:
if f.get("name") == "id" and f.get("type") == "display":
f["label_spec"] = "New Work Item"
break
elif model == "inventory":
for f in fields_spec:
if f.get("name") == "label" and f.get("type") == "display":
f["label"] = ""
f["label_spec"] = "New Inventory Item"
break
elif model == "user":
for f in fields_spec:
if f.get("name") == "label" and f.get("type") == "display":
f["label"] = ""
f["label_spec"] = "New User"
break
obj = cls()
ScopedSession = current_app.extensions["crudkit"]["Session"]
form = render_form(
cls,
obj.as_dict() if hasattr(obj, "as_dict") else {},
session=ScopedSession,
instance=obj, instance=obj,
fields_spec=fields_spec, fields_spec=fields_spec,
layout=layout, layout=layout,
submit_attrs={"class": "d-none", "disabled": True}, submit_attrs={"class": "d-none", "disabled": True},
) )
return render_template("entry.html", form=form) return render_template("entry.html", form=form)
@bp_entry.post("/entry/<model>")
def create_entry(model: str):
try:
if model not in ["inventory", "user", "worklog"]:
raise TypeError("Invalid model.")
cls = crudkit.crud.get_model(model)
svc = crudkit.crud.get_service(cls)
sess = svc.session
payload = normalize_payload(request.get_json(force=True) or {}, cls)
# Child mutations and friendly-to-FK mapping
updates = payload.pop("updates", []) or []
payload.pop("delete_update_ids", None) # irrelevant on create
if model == "worklog":
if "contact" in payload and "contact_id" not in payload:
payload["contact_id"] = payload.pop("contact")
if "work_item" in payload and "work_item_id" not in payload:
payload["work_item_id"] = payload.pop("work_item")
# Parent first, no commit yet
obj = svc.create(payload, actor="create_entry", commit=False)
# Children
if model == "worklog" and updates:
note_cls = type(obj).updates.property.mapper.class_
for item in updates:
content = (item.get("content") or "").trim() if hasattr(str, 'trim') else (item.get("content") or "").strip()
if content:
sess.add(note_cls(work_log_id=obj.id, content=content))
sess.commit()
return {"status": "success", "id": obj.id}
except Exception as e:
try:
crudkit.crud.get_service(crudkit.crud.get_model(model)).session.rollback()
except Exception:
pass
return {"status": "failure", "error": str(e)}, 400
@bp_entry.post("/entry/<model>/<int:id>") @bp_entry.post("/entry/<model>/<int:id>")
def update_entry(model, id): def update_entry(model, id):
try: try:

View file

@ -1,6 +1,6 @@
<label class="form-label">Notes</label> <label class="form-label">Notes</label>
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="me-3 w-100" id="editContainer"></div> <div class="me-3 w-100 markdown-body" id="editContainer"></div>
<script type="application/json" id="noteContent">{{ field['template_ctx']['values']['notes'] | tojson }}</script> <script type="application/json" id="noteContent">{{ field['template_ctx']['values']['notes'] | tojson }}</script>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="editSwitch" onchange="changeMode()" role="switch" <input type="checkbox" class="form-check-input" id="editSwitch" onchange="changeMode()" role="switch"
@ -9,6 +9,7 @@
</div> </div>
</div> </div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<style> <style>
textarea.auto-md { textarea.auto-md {
box-sizing: border-box; box-sizing: border-box;
@ -45,7 +46,7 @@
function renderView(md) { function renderView(md) {
const container = document.getElementById('editContainer'); const container = document.getElementById('editContainer');
if (!container) return; if (!container) return;
const html = marked.parse(md || ""); const html = marked.parse(md || "", {gfm: true});
container.innerHTML = DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] }); container.innerHTML = DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] });
for (const a of container.querySelectorAll('a[href]')) { for (const a of container.querySelectorAll('a[href]')) {
a.setAttribute('target', '_blank'); a.setAttribute('target', '_blank');

View file

@ -6,8 +6,6 @@
function formToJson(form) { function formToJson(form) {
const fd = new FormData(form); const fd = new FormData(form);
const out = {}; const out = {};
// base values
fd.forEach((value, key) => { fd.forEach((value, key) => {
if (key in out) { if (key in out) {
if (!Array.isArray(out[key])) out[key] = [out[key]]; if (!Array.isArray(out[key])) out[key] = [out[key]];
@ -16,31 +14,21 @@
out[key] = value; out[key] = value;
} }
}); });
// normalize radios and checkboxes
form.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(el => { form.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(el => {
if (!el.name) return; if (!el.name) return;
if (el.type === 'radio') { if (el.type === 'radio') {
if (out[el.name] !== undefined) return; // already set for this group if (out[el.name] !== undefined) return;
const checked = form.querySelector(`input[type="radio"][name="${CSS.escape(el.name)}"]:checked`); const checked = form.querySelector(`input[type="radio"][name="${CSS.escape(el.name)}"]:checked`);
if (checked) out[el.name] = checked.value ?? true; if (checked) out[el.name] = checked.value ?? true;
return; return;
} }
if (el.type === 'checkbox') { if (el.type === 'checkbox') {
const group = form.querySelectorAll(`input[type="checkbox"][name="${CSS.escape(el.name)}"]`); const group = form.querySelectorAll(`input[type="checkbox"][name="${CSS.escape(el.name)}"]`);
if (group.length > 1) { out[el.name] = group.length > 1
const checkedVals = Array.from(group).filter(i => i.checked).map(i => i.value ?? true); ? Array.from(group).filter(i => i.checked).map(i => i.value ?? true)
out[el.name] = checkedVals; : el.checked;
} else {
out[el.name] = el.checked;
}
} }
}); });
console.log(out);
return out; return out;
} }
@ -49,53 +37,56 @@
.map(el => Number(el.id.slice(3))) .map(el => Number(el.id.slice(3)))
.filter(Number.isFinite); .filter(Number.isFinite);
} }
function collectEditedUpdates() { function collectEditedUpdates() {
const updates = []; const updates = [];
for (const id of collectExistingUpdateIds()) { for (const id of collectExistingUpdateIds()) updates.push({ id, content: getMarkdown(id) });
updates.push({ id, content: getMarkdown(id) }); // ensure getMarkdown exists for (const md of (window.newDrafts || [])) if ((md ?? '').trim()) updates.push({ content: md });
}
for (const md of (window.newDrafts || [])) {
if ((md ?? '').trim()) updates.push({ content: md });
}
return updates; return updates;
} }
function collectDeletedIds() { return (window.deletedIds || []).filter(Number.isFinite); }
function collectDeletedIds() { // much simpler, and correct
return (window.deletedIds || []).filter(Number.isFinite); const formEl = document.getElementById({{ (field['attrs']['data-model'] ~ '_form') | tojson }});
} const model = {{ field['attrs']['data-model'] | tojson }};
const idVal = {{ field['template_ctx']['values'].get('id') | tojson }};
const hasId = idVal !== null && idVal !== undefined;
document.getElementById("{{ field['attrs']['data-model'] }}_form").addEventListener("submit", async e => { // Never call url_for for update on the "new" page.
// Create URL is fine to build server-side:
const createUrl = {{ url_for('entry.create_entry', model=field['attrs']['data-model']) | tojson }};
// Update URL is assembled on the client to avoid BuildError on "new":
const updateUrl = hasId ? `/entry/${model}/${idVal}` : null;
formEl.addEventListener("submit", async e => {
e.preventDefault(); e.preventDefault();
const json = formToJson(e.target); const json = formToJson(formEl);
json.id = {{ field['template_ctx']['values']['id'] }};
const model_name = {{ field['attrs']['data-model'] | tojson }}; if (model === 'inventory' && typeof getMarkdown === 'function') {
if(model_name === 'inventory') {
json.notes = getMarkdown().trim(); json.notes = getMarkdown().trim();
} else if (model_name === 'worklog') { } else if (model === 'worklog') {
// child mutations
json.updates = collectEditedUpdates(); json.updates = collectEditedUpdates();
json.delete_update_ids = collectDeletedIds(); json.delete_update_ids = collectDeletedIds();
} }
if (hasId) json.id = idVal;
const url = hasId ? updateUrl : createUrl;
try { try {
const response = await fetch( const res = await fetch(url, {
"{{ url_for('entry.update_entry', id=field['template_ctx']['values']['id'], model=field['attrs']['data-model']) }}",
{
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json), body: JSON.stringify(json),
} });
); const reply = await res.json();
const reply = await response.json();
if (reply.status === 'success') { if (reply.status === 'success') {
toastMessage('This entry has been successfully saved!', 'success'); toastMessage('This entry has been successfully saved!', 'success');
window.newDrafts = []; window.newDrafts = [];
window.deletedIds = []; window.deletedIds = [];
if (!hasId && reply.id) {
window.location.href = `/entry/${model}/${reply.id}?pretty=1`;
}
} else { } else {
toastMessage(`Unable to save entry: ${reply.error}`, 'danger'); toastMessage(`Unable to save entry: ${reply.error}`, 'danger');
} }

View file

@ -4,7 +4,7 @@
{% for n in items %} {% for n in items %}
<li class="list-group-item"> <li class="list-group-item">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="me-3 w-100" id="editContainer{{ n.id }}"></div> <div class="me-3 w-100 markdown-body" id="editContainer{{ n.id }}"></div>
<script type="application/json" id="md-{{ n.id }}">{{ n.content | tojson }}</script> <script type="application/json" id="md-{{ n.id }}">{{ n.content | tojson }}</script>
<div class="d-flex flex-column align-items-end"> <div class="d-flex flex-column align-items-end">
@ -25,6 +25,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<style> <style>
textarea.auto-md { textarea.auto-md {
box-sizing: border-box; box-sizing: border-box;