Compare commits

..

No commits in common. "bcf14cf2517d4eef474d01d38e5bb59c6478856a" and "01c6bb3d09f2e3140edb5e0cf27f5771f011b021" have entirely different histories.

4 changed files with 118 additions and 216 deletions

View file

@ -649,16 +649,15 @@ class CRUDService(Generic[T]):
return rows return rows
def create(self, data: dict, actor=None, *, commit: bool = True) -> T: def create(self, data: dict, actor=None) -> T:
session = self.session session = self.session
obj = self.model(**data) obj = self.model(**data)
session.add(obj) session.add(obj)
if commit:
session.commit() session.commit()
self._log_version("create", obj, actor, commit=commit) self._log_version("create", obj, actor)
return obj return obj
def update(self, id: int, data: dict, actor=None, *, commit: bool = True) -> T: def update(self, id: int, data: dict, actor=None) -> T:
session = self.session session = self.session
obj = session.get(self.model, id) obj = session.get(self.model, id)
if not obj: if not obj:
@ -696,7 +695,6 @@ class CRUDService(Generic[T]):
return obj return obj
# Commit atomically # Commit atomically
if commit:
session.commit() session.commit()
# AFTER snapshot for audit # AFTER snapshot for audit
@ -714,10 +712,10 @@ class CRUDService(Generic[T]):
return obj return obj
# Log both what we *intended* and what *actually* happened # Log both what we *intended* and what *actually* happened
self._log_version("update", obj, actor, metadata={"diff": actual, "patch": patch}, commit=commit) self._log_version("update", obj, actor, metadata={"diff": actual, "patch": patch})
return obj return obj
def delete(self, id: int, hard: bool = False, actor = None, *, commit: bool = True): def delete(self, id: int, hard: bool = False, actor = None):
session = self.session session = self.session
obj = session.get(self.model, id) obj = session.get(self.model, id)
if not obj: if not obj:
@ -727,12 +725,11 @@ class CRUDService(Generic[T]):
else: else:
soft = cast(_SoftDeletable, obj) soft = cast(_SoftDeletable, obj)
soft.is_deleted = True soft.is_deleted = True
if commit:
session.commit() session.commit()
self._log_version("delete", obj, actor, commit=commit) self._log_version("delete", obj, actor)
return obj return obj
def _log_version(self, change_type: str, obj: T, actor=None, metadata: dict | None = None, *, commit: bool = True): def _log_version(self, change_type: str, obj: T, actor=None, metadata: dict | None = None):
session = self.session session = self.session
try: try:
snapshot = {} snapshot = {}
@ -750,8 +747,7 @@ class CRUDService(Generic[T]):
meta=to_jsonable(metadata) if metadata else None, meta=to_jsonable(metadata) if metadata else None,
) )
session.add(version) session.add(version)
if commit:
session.commit() session.commit()
except Exception as e: 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() session.rollback()

View file

@ -9,28 +9,6 @@ from crudkit.core import normalize_payload
bp_entry = Blueprint("entry", __name__) 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): def init_entry_routes(app):
@bp_entry.get("/entry/<model>/<int:id>") @bp_entry.get("/entry/<model>/<int:id>")
@ -190,9 +168,6 @@ def init_entry_routes(app):
cls = crudkit.crud.get_model(model) cls = crudkit.crud.get_model(model)
payload = normalize_payload(request.get_json(), cls) payload = normalize_payload(request.get_json(), cls)
updates = payload.pop("updates", None) or []
delete_ids = payload.pop("delete_update_ids", None) or []
params = {} params = {}
if model == "inventory": if model == "inventory":
pass pass
@ -216,25 +191,16 @@ def init_entry_routes(app):
"start_time", "start_time",
"end_time", "end_time",
"complete", "complete",
"updates",
] ]
} }
else: else:
raise TypeError("Invalid model.") raise TypeError("Invalid model.")
service = crudkit.crud.get_service(cls) service = crudkit.crud.get_service(cls)
sess = service.session service.update(id, data=payload, actor="update_entry")
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} return {"status": "success", "payload": payload}
except Exception as e: except Exception as e:
print(e)
return {"status": "failure", "error": str(e)} return {"status": "failure", "error": str(e)}
app.register_blueprint(bp_entry) app.register_blueprint(bp_entry)

View file

@ -1,13 +1,9 @@
<button type="submit" class="btn btn-primary" id="submit">Save</button> <button type="submit" class="btn btn-primary" id="submit">Save</button>
<script> <script>
window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || [];
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]];
@ -17,21 +13,21 @@
} }
}); });
// 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;
} }
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) { 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; out[el.name] = checkedVals;
} else { } else {
out[el.name] = el.checked; out[el.name] = el.checked;
@ -42,61 +38,24 @@
return out; 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 => { 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'] }}; json['id'] = {{ field['template_ctx']['values']['id'] }};
// map friendly names to real FK columns if needed response = await fetch("{{ url_for('entry.update_entry', id=field['template_ctx']['values']['id'], model=field['attrs']['data-model']) }}", {
if (json.contact && !json.contact_id) json.contact_id = Number(json.contact) || null; method: "POST",
if (json.work_item && !json.work_item_id) json.work_item_id = Number(json.work_item) || null; headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(json)
});
// child mutations reply = await response.json();
json.updates = collectEditedUpdates(); if (reply['status'] === 'success') {
json.delete_update_ids = collectDeletedIds(); toastMessage("This entry has been successfully saved!", "success");
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 { } else {
toastMessage(`Unable to save entry: ${reply.error}`, 'danger'); toastMessage(`Unable to save entry: ${reply['error']}`, "danger");
}
} catch (err) {
toastMessage(`Network error: ${String(err)}`, 'danger');
} }
}); });
</script> </script>

View file

@ -5,7 +5,6 @@
<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" id="editContainer{{ n.id }}"></div>
<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">
<div id="editView{{ n.id }}"> <div id="editView{{ n.id }}">
@ -14,8 +13,8 @@
</small> </small>
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="editSwitch{{ n.id }}" onchange="changeMode({{ n.id }})" role="switch" aria-label="Edit mode for update {{ n.id }}"> <input type="checkbox" class="form-check-input" id="editSwitch{{ n.id }}" switch onchange="changeMode({{ n.id }})">
<label for="editSwitch{{ n.id }}" class="form-check-label">Edit</label> <label for="editSwitch" class="form-check-label">Edit</label>
</div> </div>
</div> </div>
</div> </div>
@ -25,101 +24,83 @@
{% endfor %} {% endfor %}
</ul> </ul>
<style>
textarea.auto-md {
box-sizing: border-box;
width: 100%;
max-height: 60vh;
overflow: hidden;
resize: vertical;
}
@supports (field-sizing: content) {
text-area.auto-md {
field-sizing: content;
}
}
</style>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script> <script>
// Initial render const contents = {};
document.addEventListener('DOMContentLoaded', () => { {% for n in items %}
const ids = [ {% for n in items %} {{ n.id }}{% if not loop.last %}, {% endif %}{% endfor %} ]; contents[{{ n.id }}] = {{ n.content | tojson }};
for (const id of ids) renderView(id, getMarkdown(id)); {% endfor %}
});
function getMarkdown(id) { document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById(`md-${id}`); for (const [id, md] of Object.entries(contents)) {
return el ? JSON.parse(el.textContent) : ""; renderView(+id, md);
}
function setMarkdown(id, md) {
const el = document.getElementById(`md-${id}`);
if (el) el.textContent = JSON.stringify(md ?? "");
} }
});
function renderView(id, md) { function renderView(id, md) {
const container = document.getElementById(`editContainer${id}`); const container = document.getElementById(`editContainer${id}`);
if (!container) return; const html = marked.parse(md ?? "");
const html = marked.parse(md || "");
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');
a.setAttribute('rel','noopener noreferrer nofollow'); a.setAttribute('rel', 'noopener noreferrer nofollow')
} }
} }
function changeMode(id) { function changeMode(id) {
const container = document.getElementById(`editContainer${id}`); const container = document.getElementById(`editContainer${id}`);
const toggle = document.getElementById(`editSwitch${id}`); const toggle = document.getElementById(`editSwitch${id}`);
if (!toggle.checked) return renderView(id, getMarkdown(id));
const current = getMarkdown(id); if (toggle.checked) {
// Switch to editor mode
const current = contents[id] ?? "";
container.dataset.prev = container.innerHTML;
container.innerHTML = ` container.innerHTML = `
<textarea class="form-control w-100 auto-md" id="editor${id}">${escapeForTextarea(current)}</textarea> <textarea class="form-control w-100" id="editor${id}" rows="6">${escapeForTextarea(current)}</textarea>
<div class="mt-2 d-flex gap-2"> <div class="mt-2 d-flex gap-2">
<button class="btn btn-primary btn-sm" onclick="saveEdit(${id})">Save</button> <button class="btn btn-primary btn-sm" onclick="saveEdit(${id})">Save</button>
<button class="btn btn-secondary btn-sm" onclick="cancelEdit(${id})">Cancel</button> <button class="btn btn-secondary btn-sm" onclick="cancelEdit(${id})">Cancel</button>
<button class="btn btn-outline-secondary btn-sm" onclick="togglePreview(${id})">Preview</button> <button class="btn btn-outline-secondary btn-sm" onclick="togglePreview(${id})">Preview</button>
</div> </div>
<div class="mt-2 border rounded p-2 d-none" id="preview${id}" aria-live="polite"></div> <div class="mt-2 border rounded p-2 d-none" id="preview${id}"></div>
`; `;
const ta = document.getElementById(`editor${id}`); } else {
autoGrow(ta); // Switch to viewer mode
ta.addEventListener('input', () => autoGrow(ta)); renderView(id, contents[id]);
}
} }
function saveEdit(id) { function saveEdit(id) {
const ta = document.getElementById(`editor${id}`); const textarea = document.getElementById(`editor${id}`);
const value = ta ? ta.value : ""; const value = textarea.value;
setMarkdown(id, value); // persist into the JSON tag contents[id] = value;
renderView(id, value); // show it renderView(id, value);
document.getElementById(`editSwitch${id}`).checked = false; document.getElementById(`editSwitch${id}`).checked = false;
} }
function cancelEdit(id) { function cancelEdit(id) {
document.getElementById(`editSwitch${id}`).checked = false; document.getElementById(`editSwitch${id}`).checked = false;
renderView(id, getMarkdown(id)); renderView(id, contents[id]);
} }
function togglePreview(id) { function togglePreview(id) {
const ta = document.getElementById(`editor${id}`); const textarea = document.getElementById(`editor${id}`);
const preview = document.getElementById(`preview${id}`); const preview = document.getElementById(`preview${id}`);
preview.classList.toggle('d-none'); preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) { if (!preview.classList.contains('d-none')) {
const html = marked.parse(ta ? ta.value : ""); const html = marked.parse(textarea.value ?? "");
preview.innerHTML = DOMPurify.sanitize(html); preview.innerHTML = DOMPurify.sanitize(html);
} }
} }
function autoGrow(ta) {
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
function escapeForTextarea(s) { function escapeForTextarea(s) {
// Keep control of what goes inside the textarea
return (s ?? "").replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return (s ?? "").replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
</script> </script>