Compare commits
2 commits
53cc90a74b
...
eff6da0a3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eff6da0a3b | ||
|
|
598a9f7793 |
4 changed files with 154 additions and 23 deletions
|
|
@ -6,7 +6,7 @@ from flask import current_app
|
||||||
from typing import Any, Callable, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast
|
from typing import Any, Callable, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast
|
||||||
from sqlalchemy import and_, func, inspect, or_, text
|
from sqlalchemy import and_, func, inspect, or_, text
|
||||||
from sqlalchemy.engine import Engine, Connection
|
from sqlalchemy.engine import Engine, Connection
|
||||||
from sqlalchemy.orm import Load, Session, with_polymorphic, Mapper, selectinload
|
from sqlalchemy.orm import Load, Session, with_polymorphic, Mapper, selectinload, with_loader_criteria
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
from sqlalchemy.sql import operators
|
from sqlalchemy.sql import operators
|
||||||
from sqlalchemy.sql.elements import UnaryExpression, ColumnElement
|
from sqlalchemy.sql.elements import UnaryExpression, ColumnElement
|
||||||
|
|
@ -186,6 +186,31 @@ class CRUDService(Generic[T]):
|
||||||
|
|
||||||
# ---- common building blocks
|
# ---- common building blocks
|
||||||
|
|
||||||
|
def _apply_soft_delete_criteria_for_children(self, query, plan: "CRUDService._Plan", params):
|
||||||
|
# Skip if caller explicitly asked for deleted
|
||||||
|
if _is_truthy((params or {}).get("include_deleted")):
|
||||||
|
return query
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for _base_alias, rel_attr, _target_alias in plan.join_paths:
|
||||||
|
prop = getattr(rel_attr, "property", None)
|
||||||
|
if not prop:
|
||||||
|
continue
|
||||||
|
target_cls = getattr(prop.mapper, "class_", None)
|
||||||
|
if not target_cls or target_cls in seen:
|
||||||
|
continue
|
||||||
|
seen.add(target_cls)
|
||||||
|
# Only apply to models that support soft delete
|
||||||
|
if hasattr(target_cls, "is_deleted"):
|
||||||
|
query = query.options(
|
||||||
|
with_loader_criteria(
|
||||||
|
target_cls,
|
||||||
|
lambda cls: cls.is_deleted == False,
|
||||||
|
include_aliases=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return query
|
||||||
|
|
||||||
def _order_clauses(self, order_spec, invert: bool = False):
|
def _order_clauses(self, order_spec, invert: bool = False):
|
||||||
clauses = []
|
clauses = []
|
||||||
for c, is_desc in zip(order_spec.cols, order_spec.desc):
|
for c, is_desc in zip(order_spec.cols, order_spec.desc):
|
||||||
|
|
@ -442,6 +467,7 @@ class CRUDService(Generic[T]):
|
||||||
plan = self._plan(params, root_alias)
|
plan = self._plan(params, root_alias)
|
||||||
query = self._apply_projection_load_only(query, root_alias, plan)
|
query = self._apply_projection_load_only(query, root_alias, plan)
|
||||||
query = self._apply_firsthop_strategies(query, root_alias, plan)
|
query = self._apply_firsthop_strategies(query, root_alias, plan)
|
||||||
|
query = self._apply_soft_delete_criteria_for_children(query, plan, params)
|
||||||
if plan.filters:
|
if plan.filters:
|
||||||
query = query.filter(*plan.filters)
|
query = query.filter(*plan.filters)
|
||||||
|
|
||||||
|
|
@ -528,6 +554,7 @@ class CRUDService(Generic[T]):
|
||||||
plan = self._plan(params, root_alias)
|
plan = self._plan(params, root_alias)
|
||||||
query = self._apply_projection_load_only(query, root_alias, plan)
|
query = self._apply_projection_load_only(query, root_alias, plan)
|
||||||
query = self._apply_firsthop_strategies(query, root_alias, plan)
|
query = self._apply_firsthop_strategies(query, root_alias, plan)
|
||||||
|
query = self._apply_soft_delete_criteria_for_children(query, plan, params)
|
||||||
if plan.filters:
|
if plan.filters:
|
||||||
query = query.filter(*plan.filters)
|
query = query.filter(*plan.filters)
|
||||||
query = query.filter(getattr(root_alias, "id") == id)
|
query = query.filter(getattr(root_alias, "id") == id)
|
||||||
|
|
@ -548,6 +575,7 @@ class CRUDService(Generic[T]):
|
||||||
query = self._apply_not_deleted(query, root_alias, params)
|
query = self._apply_not_deleted(query, root_alias, params)
|
||||||
query = self._apply_projection_load_only(query, root_alias, plan)
|
query = self._apply_projection_load_only(query, root_alias, plan)
|
||||||
query = self._apply_firsthop_strategies(query, root_alias, plan)
|
query = self._apply_firsthop_strategies(query, root_alias, plan)
|
||||||
|
query = self._apply_soft_delete_criteria_for_children(query, plan, params)
|
||||||
if plan.filters:
|
if plan.filters:
|
||||||
query = query.filter(*plan.filters)
|
query = query.filter(*plan.filters)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,6 @@ def init_entry_routes(app):
|
||||||
for k in ("brand_id", "type_id", "owner_id", "location_id", "image_id"):
|
for k in ("brand_id", "type_id", "owner_id", "location_id", "image_id"):
|
||||||
if payload.get(k) == "":
|
if payload.get(k) == "":
|
||||||
payload[k] = None
|
payload[k] = None
|
||||||
# payload["timestamp"] = datetime.now()
|
|
||||||
|
|
||||||
if model == "worklog":
|
if model == "worklog":
|
||||||
if "contact" in payload and "contact_id" not in payload:
|
if "contact" in payload and "contact_id" not in payload:
|
||||||
|
|
@ -285,13 +284,17 @@ def init_entry_routes(app):
|
||||||
# Parent first, no commit yet
|
# Parent first, no commit yet
|
||||||
obj = svc.create(payload, actor="create_entry", commit=False)
|
obj = svc.create(payload, actor="create_entry", commit=False)
|
||||||
|
|
||||||
|
# Ensure PK is available for children and relationship auto-FK works
|
||||||
|
sess.flush()
|
||||||
|
|
||||||
# Children
|
# Children
|
||||||
if model == "worklog" and updates:
|
if model == "worklog" and updates:
|
||||||
note_cls = type(obj).updates.property.mapper.class_
|
note_mapper = type(obj).updates.property.mapper
|
||||||
|
note_cls = note_mapper.class_
|
||||||
for item in updates:
|
for item in updates:
|
||||||
content = (item.get("content") or "").trim() if hasattr(str, 'trim') else (item.get("content") or "").strip()
|
content = (item.get("content") or "").strip()
|
||||||
if content:
|
if content:
|
||||||
sess.add(note_cls(work_log_id=obj.id, content=content))
|
obj.updates.append(note_cls(content=content))
|
||||||
|
|
||||||
sess.commit()
|
sess.commit()
|
||||||
return {"status": "success", "id": obj.id}
|
return {"status": "success", "id": obj.id}
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,18 @@
|
||||||
.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()) updates.push({ id, content: getMarkdown(id) });
|
const deleted = new Set(collectDeletedIds());
|
||||||
|
for (const id of collectExistingUpdateIds()) {
|
||||||
|
if(deleted.has(id)) continue; // skip ones marked for deletion
|
||||||
|
updates.push({ id, content: getMarkdown(id) });
|
||||||
|
}
|
||||||
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() { return (window.deletedIds || []).filter(Number.isFinite); }
|
||||||
|
|
||||||
// much simpler, and correct
|
// much simpler, and correct
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,37 @@
|
||||||
{{ n.timestamp.strftime("%Y-%m-%d %H:%M") if n.timestamp else "" }}
|
{{ n.timestamp.strftime("%Y-%m-%d %H:%M") if n.timestamp else "" }}
|
||||||
</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 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{{ n.id }}" class="form-check-label">Edit</label>
|
<label for="editSwitch{{ n.id }}" class="form-check-label">Edit</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm" id="deleteBtn{{ n.id }}" onclick="toggleDelete({{ n.id }})">Delete</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="list-group-item text-muted">No updates yet.</li>
|
<li id="noUpdatesRow" class="list-group-item text-muted">No updates yet.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label">Add update</label>
|
||||||
|
<textarea id="newUpdateInput" class="form-control auto-md" rows="3" placeholder="Write a new update..."></textarea>
|
||||||
|
<div class="mt-2 d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" onclick="addNewDraft()">Add</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="clearNewDraft()">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="newDraftsList" class="mt-3 d-none">
|
||||||
|
<div class="fw-semibold mb-1">Pending new updates</div>
|
||||||
|
<ul class="list-group" id="newDraftsUl"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
<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 {
|
||||||
|
|
@ -45,6 +64,81 @@
|
||||||
<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>
|
||||||
|
window.newDrafts = window.newDrafts || [];
|
||||||
|
window.deletedIds = window.deletedIds || [];
|
||||||
|
|
||||||
|
function addNewDraft() {
|
||||||
|
const ta = document.getElementById('newUpdateInput');
|
||||||
|
const text = (ta.value || '').trim()
|
||||||
|
if (!text) return;
|
||||||
|
window.newDrafts.push(text);
|
||||||
|
ta.value = '';
|
||||||
|
renderNewDrafts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearNewDraft() {
|
||||||
|
const ta = document.getElementById('newUpdateInput');
|
||||||
|
ta.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNewDrafts() {
|
||||||
|
const wrap = document.getElementById('newDraftsList');
|
||||||
|
const ul = document.getElementById('newDraftsUl');
|
||||||
|
ul.innerHTML = '';
|
||||||
|
const drafts = window.newDrafts || [];
|
||||||
|
if (!drafts.length) {
|
||||||
|
wrap.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.classList.remove('d-none');
|
||||||
|
drafts.forEach((md, idx) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'list-group-item d-flex justify-content-between align-items-start';
|
||||||
|
const left = document.createElement('div');
|
||||||
|
left.className = 'w-100 markdown-body';
|
||||||
|
left.innerHTML = DOMPurify.sanitize(marked.parse(md || ''));
|
||||||
|
for (const a of left.querySelectorAll('a[href]')) {
|
||||||
|
a.setAttribute('target','_blank');
|
||||||
|
a.setAttribute('rel','noopener noreferrer nofollow');
|
||||||
|
}
|
||||||
|
const right = document.createElement('div');
|
||||||
|
right.className = 'ms-3 d-flex flex-column align-items-end';
|
||||||
|
right.innerHTML = `<button type="button" class="btn btn-outline-danger btn-sm" onclick="deleteDraft(${idx})">Remove</button>"`;
|
||||||
|
li.appendChild(left);
|
||||||
|
li.appendChild(right);
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
const noRow = document.getElementById('noUpdatesRow');
|
||||||
|
if (noRow) {
|
||||||
|
if (drafts.length) noRow.classList.add('d-none');
|
||||||
|
else noRow.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDraft(idx) {
|
||||||
|
if (!Array.isArray(window.newDrafts)) return;
|
||||||
|
window.newDrafts.splice(idx, 1);
|
||||||
|
renderNewDrafts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDelete(id) {
|
||||||
|
window.deletedIds = window.deletedIds || [];
|
||||||
|
const idx = window.deletedIds.indexOf(id);
|
||||||
|
const btn = document.getElementById(`deleteBtn${id}`);
|
||||||
|
const container = document.getElementById(`editContainer${id}`);
|
||||||
|
if (idx === -1) {
|
||||||
|
window.deletedIds.push(id);
|
||||||
|
if (btn) btn.classList.replace('btn-outline-danger', 'btn-danger');
|
||||||
|
if (btn) btn.textContent = 'Undelete';
|
||||||
|
if (container) container.style.opacity = '0.5';
|
||||||
|
} else {
|
||||||
|
window.deletedIds.splice(idx, 1);
|
||||||
|
if (btn) btn.classList.replace('btn-danger', 'btn-outline-danger');
|
||||||
|
if (btn) btn.textContent = 'Delete';
|
||||||
|
if (container) container.style.opacity = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const ids = [{% for n in items %} {{ n.id }}{% if not loop.last %}, {% endif %} {% endfor %} ];
|
const ids = [{% for n in items %} {{ n.id }}{% if not loop.last %}, {% endif %} {% endfor %} ];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue