Compare commits
2 commits
01c6bb3d09
...
bcf14cf251
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcf14cf251 | ||
|
|
85b0e576c7 |
4 changed files with 216 additions and 118 deletions
|
|
@ -649,15 +649,16 @@ class CRUDService(Generic[T]):
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def create(self, data: dict, actor=None) -> T:
|
def create(self, data: dict, actor=None, *, commit: bool = True) -> 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)
|
self._log_version("create", obj, actor, commit=commit)
|
||||||
return obj
|
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
|
session = self.session
|
||||||
obj = session.get(self.model, id)
|
obj = session.get(self.model, id)
|
||||||
if not obj:
|
if not obj:
|
||||||
|
|
@ -695,6 +696,7 @@ 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
|
||||||
|
|
@ -712,10 +714,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})
|
self._log_version("update", obj, actor, metadata={"diff": actual, "patch": patch}, commit=commit)
|
||||||
return obj
|
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
|
session = self.session
|
||||||
obj = session.get(self.model, id)
|
obj = session.get(self.model, id)
|
||||||
if not obj:
|
if not obj:
|
||||||
|
|
@ -725,11 +727,12 @@ 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)
|
self._log_version("delete", obj, actor, commit=commit)
|
||||||
return obj
|
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
|
session = self.session
|
||||||
try:
|
try:
|
||||||
snapshot = {}
|
snapshot = {}
|
||||||
|
|
@ -747,7 +750,8 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,28 @@ 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>")
|
||||||
|
|
@ -168,6 +190,9 @@ 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
|
||||||
|
|
@ -191,16 +216,25 @@ 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)
|
||||||
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}
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
<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]];
|
||||||
|
|
@ -13,21 +17,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;
|
if (out[el.name] !== undefined) return; // already set for this group
|
||||||
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)
|
const checkedVals = Array.from(group).filter(i => i.checked).map(i => i.value ?? true);
|
||||||
.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;
|
||||||
|
|
@ -38,24 +42,61 @@
|
||||||
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'] }};
|
||||||
|
|
||||||
response = await fetch("{{ url_for('entry.update_entry', id=field['template_ctx']['values']['id'], model=field['attrs']['data-model']) }}", {
|
// map friendly names to real FK columns if needed
|
||||||
method: "POST",
|
if (json.contact && !json.contact_id) json.contact_id = Number(json.contact) || null;
|
||||||
headers: {
|
if (json.work_item && !json.work_item_id) json.work_item_id = Number(json.work_item) || null;
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(json)
|
|
||||||
});
|
|
||||||
|
|
||||||
reply = await response.json();
|
// child mutations
|
||||||
if (reply['status'] === 'success') {
|
json.updates = collectEditedUpdates();
|
||||||
toastMessage("This entry has been successfully saved!", "success");
|
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 {
|
} 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>
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
<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 }}">
|
||||||
|
|
@ -13,8 +14,8 @@
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input type="checkbox" class="form-check-input" id="editSwitch{{ n.id }}" switch onchange="changeMode({{ n.id }})">
|
<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 }}">
|
||||||
<label for="editSwitch" class="form-check-label">Edit</label>
|
<label for="editSwitch{{ n.id }}" class="form-check-label">Edit</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -24,83 +25,101 @@
|
||||||
{% 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>
|
||||||
const contents = {};
|
// Initial render
|
||||||
{% for n in items %}
|
|
||||||
contents[{{ n.id }}] = {{ n.content | tojson }};
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
for (const [id, md] of Object.entries(contents)) {
|
const ids = [ {% for n in items %} {{ n.id }}{% if not loop.last %}, {% endif %}{% endfor %} ];
|
||||||
renderView(+id, md);
|
for (const id of ids) renderView(id, getMarkdown(id));
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getMarkdown(id) {
|
||||||
|
const el = document.getElementById(`md-${id}`);
|
||||||
|
return el ? JSON.parse(el.textContent) : "";
|
||||||
|
}
|
||||||
|
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}`);
|
||||||
const html = marked.parse(md ?? "");
|
if (!container) return;
|
||||||
|
const html = marked.parse(md || "");
|
||||||
container.innerHTML = DOMPurify.sanitize(html, {
|
container.innerHTML = DOMPurify.sanitize(html, { ADD_ATTR: ['target','rel'] });
|
||||||
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));
|
||||||
|
|
||||||
if (toggle.checked) {
|
const current = getMarkdown(id);
|
||||||
// Switch to editor mode
|
|
||||||
const current = contents[id] ?? "";
|
|
||||||
container.dataset.prev = container.innerHTML;
|
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<textarea class="form-control w-100" id="editor${id}" rows="6">${escapeForTextarea(current)}</textarea>
|
<textarea class="form-control w-100 auto-md" id="editor${id}">${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}"></div>
|
<div class="mt-2 border rounded p-2 d-none" id="preview${id}" aria-live="polite"></div>
|
||||||
`;
|
`;
|
||||||
} else {
|
const ta = document.getElementById(`editor${id}`);
|
||||||
// Switch to viewer mode
|
autoGrow(ta);
|
||||||
renderView(id, contents[id]);
|
ta.addEventListener('input', () => autoGrow(ta));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveEdit(id) {
|
function saveEdit(id) {
|
||||||
const textarea = document.getElementById(`editor${id}`);
|
const ta = document.getElementById(`editor${id}`);
|
||||||
const value = textarea.value;
|
const value = ta ? ta.value : "";
|
||||||
contents[id] = value;
|
setMarkdown(id, value); // persist into the JSON tag
|
||||||
renderView(id, value);
|
renderView(id, value); // show it
|
||||||
|
|
||||||
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, contents[id]);
|
renderView(id, getMarkdown(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePreview(id) {
|
function togglePreview(id) {
|
||||||
const textarea = document.getElementById(`editor${id}`);
|
const ta = 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(textarea.value ?? "");
|
const html = marked.parse(ta ? ta.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,'&').replace(/</g,'<').replace(/>/g,'>');
|
return (s ?? "").replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue