Fix soft delete not being recognized.

This commit is contained in:
Yaro Kasear 2025-10-07 13:15:30 -05:00
parent 598a9f7793
commit eff6da0a3b
3 changed files with 101 additions and 5 deletions

View file

@ -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)

View file

@ -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

View file

@ -13,16 +13,20 @@
{{ 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 }})" <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 }}">
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>
@ -77,6 +81,64 @@
ta.value = ''; 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 %} ];