Auto-commit local changes before build (2026-01-06 10:02:17) #36
@ -1 +1 @@
|
|||||||
v20260106-01-m365-combined-job-name-merge
|
v20260106-02-inbox-bulk-delete
|
||||||
|
|||||||
@ -69,6 +69,8 @@ def inbox():
|
|||||||
has_prev=has_prev,
|
has_prev=has_prev,
|
||||||
has_next=has_next,
|
has_next=has_next,
|
||||||
customers=customer_rows,
|
customers=customer_rows,
|
||||||
|
can_bulk_delete=(get_active_role() in ("admin", "operator")),
|
||||||
|
is_admin=(get_active_role() == "admin"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -296,6 +298,62 @@ def inbox_message_delete(message_id: int):
|
|||||||
return redirect(url_for("main.inbox"))
|
return redirect(url_for("main.inbox"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/api/inbox/delete")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator")
|
||||||
|
def api_inbox_bulk_delete():
|
||||||
|
"""Bulk delete inbox messages (soft delete -> move to Deleted)."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
message_ids = data.get("message_ids") or []
|
||||||
|
|
||||||
|
try:
|
||||||
|
message_ids = [int(x) for x in message_ids]
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"status": "error", "message": "Invalid message_ids."}), 400
|
||||||
|
|
||||||
|
if not message_ids:
|
||||||
|
return jsonify({"status": "ok", "updated": 0, "skipped": 0, "missing": 0})
|
||||||
|
|
||||||
|
msgs = MailMessage.query.filter(MailMessage.id.in_(message_ids)).all()
|
||||||
|
msg_map = {int(m.id): m for m in msgs}
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
missing = 0
|
||||||
|
|
||||||
|
for mid in message_ids:
|
||||||
|
msg = msg_map.get(int(mid))
|
||||||
|
if not msg:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if getattr(msg, "location", "inbox") != "inbox":
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if hasattr(msg, "location"):
|
||||||
|
msg.location = "deleted"
|
||||||
|
if hasattr(msg, "deleted_at"):
|
||||||
|
msg.deleted_at = now
|
||||||
|
if hasattr(msg, "deleted_by_user_id"):
|
||||||
|
msg.deleted_by_user_id = current_user.id
|
||||||
|
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
_log_admin_event("inbox_bulk_delete_error", f"Failed to bulk delete inbox messages {message_ids}: {exc}")
|
||||||
|
return jsonify({"status": "error", "message": "Database error while deleting messages."}), 500
|
||||||
|
|
||||||
|
_log_admin_event("inbox_bulk_delete", f"Deleted inbox messages: {message_ids}")
|
||||||
|
return jsonify({"status": "ok", "updated": updated, "skipped": skipped, "missing": missing})
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/inbox/deleted")
|
@main_bp.route("/inbox/deleted")
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin")
|
@roles_required("admin")
|
||||||
|
|||||||
@ -56,10 +56,26 @@
|
|||||||
|
|
||||||
{{ pager("top", page, total_pages, has_prev, has_next) }}
|
{{ pager("top", page, total_pages, has_prev, has_next) }}
|
||||||
|
|
||||||
|
{% if can_bulk_delete %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" id="btn_inbox_delete_selected" disabled>Delete selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mb-2" id="inbox_status"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover align-middle">
|
<table class="table table-sm table-hover align-middle" id="inboxTable">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
{% if can_bulk_delete %}
|
||||||
|
<th scope="col" style="width: 34px;">
|
||||||
|
<input class="form-check-input" type="checkbox" id="inbox_select_all" />
|
||||||
|
</th>
|
||||||
|
{% endif %}
|
||||||
<th scope="col">From</th>
|
<th scope="col">From</th>
|
||||||
<th scope="col">Subject</th>
|
<th scope="col">Subject</th>
|
||||||
<th scope="col">Date / time</th>
|
<th scope="col">Date / time</th>
|
||||||
@ -75,6 +91,11 @@
|
|||||||
{% if rows %}
|
{% if rows %}
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr class="inbox-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
|
<tr class="inbox-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
|
||||||
|
{% if can_bulk_delete %}
|
||||||
|
<td onclick="event.stopPropagation();">
|
||||||
|
<input class="form-check-input inbox_row_cb" type="checkbox" value="{{ row.id }}" />
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
<td>{{ row.from_address }}</td>
|
<td>{{ row.from_address }}</td>
|
||||||
<td>{{ row.subject }}</td>
|
<td>{{ row.subject }}</td>
|
||||||
<td>{{ row.received_at }}</td>
|
<td>{{ row.received_at }}</td>
|
||||||
@ -190,6 +211,125 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var customers = {{ customers|tojson|safe }};
|
var customers = {{ customers|tojson|safe }};
|
||||||
|
|
||||||
|
var table = document.getElementById('inboxTable');
|
||||||
|
var selectAll = document.getElementById('inbox_select_all');
|
||||||
|
var btnDeleteSelected = document.getElementById('btn_inbox_delete_selected');
|
||||||
|
var statusEl = document.getElementById('inbox_status');
|
||||||
|
|
||||||
|
function getSelectedMessageIds() {
|
||||||
|
if (!table) return [];
|
||||||
|
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
|
||||||
|
var ids = [];
|
||||||
|
cbs.forEach(function (cb) {
|
||||||
|
if (cb.checked) ids.push(parseInt(cb.value, 10));
|
||||||
|
});
|
||||||
|
return ids.filter(function (x) { return Number.isFinite(x); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshRowHighlights() {
|
||||||
|
if (!table) return;
|
||||||
|
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
|
||||||
|
cbs.forEach(function (cb) {
|
||||||
|
var tr = cb.closest ? cb.closest('tr') : null;
|
||||||
|
if (!tr) return;
|
||||||
|
if (cb.checked) tr.classList.add('table-active');
|
||||||
|
else tr.classList.remove('table-active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSelectAll() {
|
||||||
|
if (!selectAll || !table) return;
|
||||||
|
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
|
||||||
|
var total = cbs.length;
|
||||||
|
var checked = 0;
|
||||||
|
cbs.forEach(function (cb) { if (cb.checked) checked++; });
|
||||||
|
selectAll.indeterminate = checked > 0 && checked < total;
|
||||||
|
selectAll.checked = total > 0 && checked === total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkDeleteUi() {
|
||||||
|
var ids = getSelectedMessageIds();
|
||||||
|
refreshRowHighlights();
|
||||||
|
if (btnDeleteSelected) btnDeleteSelected.disabled = ids.length === 0;
|
||||||
|
if (statusEl) statusEl.textContent = ids.length ? (ids.length + ' selected') : '';
|
||||||
|
refreshSelectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJson(url, payload) {
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data }; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectAll && table) {
|
||||||
|
function setAllSelection(checked) {
|
||||||
|
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
|
||||||
|
cbs.forEach(function (cb) { cb.checked = !!checked; });
|
||||||
|
selectAll.indeterminate = false;
|
||||||
|
selectAll.checked = !!checked;
|
||||||
|
setTimeout(function () {
|
||||||
|
selectAll.indeterminate = false;
|
||||||
|
selectAll.checked = !!checked;
|
||||||
|
}, 0);
|
||||||
|
updateBulkDeleteUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
selectAll.addEventListener('change', function () {
|
||||||
|
setAllSelection(selectAll.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
table.addEventListener('change', function (e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (t && t.classList && t.classList.contains('inbox_row_cb')) {
|
||||||
|
updateBulkDeleteUi();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnDeleteSelected) {
|
||||||
|
btnDeleteSelected.addEventListener('click', function () {
|
||||||
|
var ids = getSelectedMessageIds();
|
||||||
|
if (!ids.length) return;
|
||||||
|
|
||||||
|
var msg = 'Delete ' + ids.length + ' selected message' + (ids.length === 1 ? '' : 's') + ' from the Inbox?';
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
if (statusEl) statusEl.textContent = 'Deleting...';
|
||||||
|
|
||||||
|
postJson('{{ url_for('main.api_inbox_bulk_delete') }}', { message_ids: ids })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok || !res.data || res.data.status !== 'ok') {
|
||||||
|
var err = (res.data && (res.data.message || res.data.error)) ? (res.data.message || res.data.error) : 'Request failed.';
|
||||||
|
if (statusEl) statusEl.textContent = err;
|
||||||
|
alert(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
var err = 'Request failed.';
|
||||||
|
if (statusEl) statusEl.textContent = err;
|
||||||
|
alert(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize UI state
|
||||||
|
updateBulkDeleteUi();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function wrapMailHtml(html) {
|
function wrapMailHtml(html) {
|
||||||
|
|||||||
@ -4,6 +4,14 @@
|
|||||||
- Job names containing the suffix "(Combined)" are now mapped to the same job as the corresponding job without this suffix.
|
- Job names containing the suffix "(Combined)" are now mapped to the same job as the corresponding job without this suffix.
|
||||||
- This ensures that combined and non-combined backup result emails are aggregated under a single job in reports and statistics.
|
- This ensures that combined and non-combined backup result emails are aggregated under a single job in reports and statistics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v20260106-02-inbox-bulk-delete
|
||||||
|
|
||||||
|
- Added multi-select checkboxes to the Inbox for users with the operator or admin role.
|
||||||
|
- Added a “Delete selected” bulk action that soft-deletes selected inbox messages (moves them to Deleted), including select-all support and a selected-count indicator.
|
||||||
|
- Implemented a new POST API endpoint to bulk delete inbox messages with proper validation, skip/missing counts, and admin event logging.
|
||||||
|
|
||||||
|
|
||||||
================================================================================================================================================
|
================================================================================================================================================
|
||||||
## v0.1.16
|
## v0.1.16
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user