Merge pull request 'Auto-commit local changes before build (2026-01-12 13:32:27)' (#96) from v20260112-09-veeam-vspc-company-mapping-popup into main

Reviewed-on: #96
This commit is contained in:
Ivo Oskamp 2026-01-13 11:42:47 +01:00
commit fd175200db
5 changed files with 432 additions and 2 deletions

View File

@ -1 +1 @@
v20260112-08-fix-veeam-vspc-parser-syntaxerror v20260112-09-veeam-vspc-company-mapping-popup

View File

@ -285,6 +285,155 @@ def inbox_message_approve(message_id: int):
@main_bp.route("/inbox/message/<int:message_id>/delete", methods=["POST"]) @main_bp.route("/inbox/message/<int:message_id>/delete", methods=["POST"])
@login_required @login_required
@roles_required("admin", "operator") @roles_required("admin", "operator")
@main_bp.route("/inbox/<int:message_id>/approve_vspc_companies", methods=["POST"])
@roles_required("admin", "operator")
def inbox_message_approve_vspc_companies(message_id: int):
msg = MailMessage.query.get_or_404(message_id)
# Only allow approval from inbox
if getattr(msg, "location", "inbox") != "inbox":
flash("This message is no longer in the Inbox and cannot be approved here.", "warning")
return redirect(url_for("main.inbox"))
mappings_json = (request.form.get("company_mappings_json") or "").strip()
if not mappings_json:
flash("Please map at least one company before approving.", "danger")
return redirect(url_for("main.inbox"))
try:
mappings = json.loads(mappings_json)
except Exception:
flash("Invalid company mappings payload.", "danger")
return redirect(url_for("main.inbox"))
if not isinstance(mappings, list) or not mappings:
flash("Please map at least one company before approving.", "danger")
return redirect(url_for("main.inbox"))
# Validate message type (best-effort guard)
if (getattr(msg, "backup_software", None) or "").strip() != "Veeam" or (getattr(msg, "backup_type", None) or "").strip() != "Service Provider Console":
flash("This approval method is only valid for Veeam Service Provider Console summary emails.", "danger")
return redirect(url_for("main.inbox"))
created_runs: list[JobRun] = []
first_job: Job | None = None
for item in mappings:
if not isinstance(item, dict):
continue
company = (item.get("company") or "").strip()
customer_id_raw = str(item.get("customer_id") or "").strip()
if not company or not customer_id_raw:
continue
try:
customer_id = int(customer_id_raw)
except ValueError:
continue
customer = Customer.query.get(customer_id)
if not customer:
continue
# Build / find job for this specific company
norm_from, store_backup, store_type, _store_job = build_job_match_key(msg)
company_job_name = f"{(msg.job_name or 'Active alarms summary').strip()} | {company}".strip()
tmp_msg = MailMessage(
from_address=norm_from,
backup_software=store_backup,
backup_type=store_type,
job_name=company_job_name,
)
job = find_matching_job(tmp_msg)
if job:
# Ensure the job is assigned to the selected customer
if job.customer_id != customer.id:
job.customer_id = customer.id
else:
job = Job(
customer_id=customer.id,
from_address=norm_from,
backup_software=store_backup,
backup_type=store_type,
job_name=company_job_name,
active=True,
auto_approve=True,
)
db.session.add(job)
db.session.flush()
if not first_job:
first_job = job
# Derive per-company status from the mail objects
objs = (
MailObject.query.filter(MailObject.mail_message_id == msg.id)
.filter(MailObject.object_name.like(f"{company} | %"))
.all()
)
saw_error = any((o.status or "").lower() == "error" for o in objs)
saw_warning = any((o.status or "").lower() == "warning" for o in objs)
status = "Error" if saw_error else ("Warning" if saw_warning else (msg.overall_status or "Success"))
run = JobRun(
job_id=job.id,
mail_message_id=msg.id,
run_at=(msg.received_at or getattr(msg, "parsed_at", None) or datetime.utcnow()),
status=status or None,
missed=False,
)
if hasattr(run, "remark"):
run.remark = getattr(msg, "overall_message", None)
db.session.add(run)
db.session.flush() # ensure run.id
# Persist only objects belonging to this company into the customer's object space
try:
persist_objects_for_approved_run_filtered(customer.id, job.id, run.id, msg.id, object_name_prefix=company, strip_prefix=True)
except Exception as exc:
_log_admin_event(
"object_persist_error",
f"Filtered object persistence failed for message {msg.id} (company '{company}', job {job.id}, run {run.id}): {exc}",
)
created_runs.append(run)
if not created_runs:
flash("No valid company/customer mappings were provided.", "danger")
return redirect(url_for("main.inbox"))
# Update mail message to reflect approval
msg.job_id = first_job.id if first_job else None
if hasattr(msg, "approved"):
msg.approved = True
if hasattr(msg, "approved_at"):
msg.approved_at = datetime.utcnow()
if hasattr(msg, "approved_by_id"):
msg.approved_by_id = current_user.id
if hasattr(msg, "location"):
msg.location = "history"
try:
db.session.commit()
except Exception as exc:
db.session.rollback()
flash("Could not approve this job due to a database error.", "danger")
_log_admin_event("inbox_approve_error", f"Failed to approve VSPC message {msg.id}: {exc}")
return redirect(url_for("main.inbox"))
_log_admin_event(
"inbox_approve_vspc",
f"Approved VSPC message {msg.id} into {len(created_runs)} runs (job_id={msg.job_id})",
)
flash(f"Approved VSPC summary into {len(created_runs)} run(s).", "success")
return redirect(url_for("main.inbox"))
def inbox_message_delete(message_id: int): def inbox_message_delete(message_id: int):
msg = MailMessage.query.get_or_404(message_id) msg = MailMessage.query.get_or_404(message_id)

View File

@ -130,3 +130,127 @@ def persist_objects_for_approved_run(customer_id: int, job_id: int, run_id: int,
return processed return processed
def persist_objects_for_approved_run_filtered(
customer_id: int,
job_id: int,
run_id: int,
mail_message_id: int,
*,
object_name_prefix: str,
strip_prefix: bool = True,
) -> int:
"""Persist a subset of mail_objects for a specific approved run.
This is used for multi-tenant / multi-customer summary emails where a single mail_message
contains objects for multiple companies (e.g. Veeam VSPC Active Alarms summary).
Args:
customer_id: Customer id for the target job.
job_id: Job id for the target job.
run_id: JobRun id.
mail_message_id: MailMessage id that contains the parsed mail_objects.
object_name_prefix: Company prefix (exact) used in mail_objects.object_name ("<company> | <object>").
strip_prefix: If True, store object names without the "<company> | " prefix.
Returns:
Number of processed objects.
"""
engine = db.get_engine()
processed = 0
prefix = (object_name_prefix or "").strip()
if not prefix:
return 0
like_value = f"{prefix} | %"
with engine.begin() as conn:
rows = conn.execute(
text(
"""
SELECT object_name, object_type, status, error_message
FROM mail_objects
WHERE mail_message_id = :mail_message_id
AND object_name LIKE :like_value
ORDER BY id
"""
),
{"mail_message_id": mail_message_id, "like_value": like_value},
).fetchall()
for r in rows:
raw_name = (r[0] or "").strip()
if not raw_name:
continue
object_name = raw_name
if strip_prefix and object_name.startswith(f"{prefix} | "):
object_name = object_name[len(prefix) + 3 :].strip()
if not object_name:
continue
object_type = r[1]
status = r[2]
error_message = r[3]
# 1) Upsert customer_objects and get id
customer_object_id = conn.execute(
text(
"""
INSERT INTO customer_objects (customer_id, object_name, object_type)
VALUES (:customer_id, :object_name, :object_type)
ON CONFLICT (customer_id, object_name, COALESCE(object_type, ''))
DO UPDATE SET object_type = EXCLUDED.object_type
RETURNING id
"""
),
{
"customer_id": customer_id,
"object_name": object_name,
"object_type": object_type,
},
).scalar()
# 2) Upsert job_object_links
conn.execute(
text(
"""
INSERT INTO job_object_links (job_id, customer_object_id)
VALUES (:job_id, :customer_object_id)
ON CONFLICT (job_id, customer_object_id) DO NOTHING
"""
),
{
"job_id": job_id,
"customer_object_id": customer_object_id,
},
)
# 3) Upsert run_object_links
conn.execute(
text(
"""
INSERT INTO run_object_links (run_id, customer_object_id, status, error_message, observed_at)
VALUES (:run_id, :customer_object_id, :status, :error_message, NOW())
ON CONFLICT (run_id, customer_object_id)
DO UPDATE SET
status = EXCLUDED.status,
error_message = EXCLUDED.error_message,
observed_at = NOW()
"""
),
{
"run_id": run_id,
"customer_object_id": customer_object_id,
"status": status,
"error_message": error_message,
},
)
processed += 1
_update_override_applied_for_run(job_id, run_id)
return processed

View File

@ -195,7 +195,8 @@
{% if current_user.is_authenticated and active_role in ["admin", "operator"] %} {% if current_user.is_authenticated and active_role in ["admin", "operator"] %}
<form id="inboxApproveForm" method="POST" action="" class="me-auto mb-0"> <form id="inboxApproveForm" method="POST" action="" class="me-auto mb-0">
<input type="hidden" id="msg_customer_id" name="customer_id" value="" /> <input type="hidden" id="msg_customer_id" name="customer_id" value="" />
<button type="submit" class="btn btn-primary">Approve job</button> <button type="submit" class="btn btn-primary" id="inboxApproveBtn">Approve job</button>
<button type="button" class="btn btn-outline-primary ms-2 d-none" id="vspcMapCompaniesBtn">Map companies</button>
</form> </form>
<form id="inboxDeleteForm" method="POST" action="" class="mb-0"> <form id="inboxDeleteForm" method="POST" action="" class="mb-0">
<button type="submit" class="btn btn-outline-danger" onclick="return confirm('Delete this message from the Inbox?');">Delete</button> <button type="submit" class="btn btn-outline-danger" onclick="return confirm('Delete this message from the Inbox?');">Delete</button>
@ -207,6 +208,50 @@
</div> </div>
</div> </div>
<!-- VSPC company mapping modal (for multi-company summary emails) -->
<div class="modal fade" id="vspcCompanyMapModal" tabindex="-1" aria-labelledby="vspcCompanyMapModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="vspcCompanyMapModalLabel">Map companies to customers</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="vspcCompanyMapForm" method="POST" action="">
<div class="modal-body">
<p class="mb-2">This message contains multiple companies. Map each company to a customer to approve.</p>
<datalist id="vspcCustomerList">
{% for c in customers %}
<option value="{{ c.name }}"></option>
{% endfor %}
</datalist>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th style="width: 40%;">Company</th>
<th style="width: 60%;">Customer</th>
</tr>
</thead>
<tbody id="vspcCompanyMapTbody">
<!-- rows injected by JS -->
</tbody>
</table>
</div>
<input type="hidden" id="vspc_company_mappings_json" name="company_mappings_json" value="" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Approve mapped companies</button>
</div>
</form>
</div>
</div>
</div>
<script> <script>
(function () { (function () {
var customers = {{ customers|tojson|safe }}; var customers = {{ customers|tojson|safe }};
@ -455,6 +500,107 @@ function findCustomerIdByName(name) {
renderObjects(data.objects || []); renderObjects(data.objects || []);
// VSPC multi-company mapping support (Active alarms summary)
(function () {
var mapBtn = document.getElementById("vspcMapCompaniesBtn");
var approveBtn = document.getElementById("inboxApproveBtn");
if (!mapBtn) return;
// reset
mapBtn.classList.add("d-none");
if (approveBtn) approveBtn.classList.remove("d-none");
var bsw = String(meta.backup_software || "").trim();
var btype = String(meta.backup_type || "").trim();
var jname = String(meta.job_name || "").trim();
if (bsw !== "Veeam" || btype !== "Service Provider Console" || jname !== "Active alarms summary") {
return;
}
var objs = data.objects || [];
var companies = [];
var seen = {};
objs.forEach(function (o) {
var name = String((o && o.name) || "");
var ix = name.indexOf(" | ");
if (ix > 0) {
var c = name.substring(0, ix).trim();
if (c && !seen[c]) { seen[c] = true; companies.push(c); }
}
});
if (!companies.length) return;
// Show mapping button; hide regular approve
mapBtn.classList.remove("d-none");
if (approveBtn) approveBtn.classList.add("d-none");
mapBtn.onclick = function () {
var tbody = document.getElementById("vspcCompanyMapTbody");
var form = document.getElementById("vspcCompanyMapForm");
if (!tbody || !form) return;
// set form action
form.action = "{{ url_for('main.inbox_message_approve_vspc_companies', message_id=0) }}".replace("0", String(meta.id || id));
// build rows
tbody.innerHTML = "";
companies.forEach(function (company) {
var tr = document.createElement("tr");
var tdC = document.createElement("td");
tdC.textContent = company;
tr.appendChild(tdC);
var tdS = document.createElement("td");
var inp = document.createElement("input");
inp.type = "text";
inp.className = "form-control form-control-sm";
inp.setAttribute("list", "vspcCustomerList");
inp.setAttribute("data-company", company);
inp.placeholder = "Select customer";
tdS.appendChild(inp);
tr.appendChild(tdS);
tbody.appendChild(tr);
});
// clear hidden field
var hidden = document.getElementById("vspc_company_mappings_json");
if (hidden) hidden.value = "";
var mapModalEl = document.getElementById("vspcCompanyMapModal");
if (mapModalEl && window.bootstrap) {
var mm = bootstrap.Modal.getOrCreateInstance(mapModalEl);
mm.show();
}
};
// Attach submit handler once
var mapForm = document.getElementById("vspcCompanyMapForm");
if (mapForm && !mapForm.getAttribute("data-bound")) {
mapForm.setAttribute("data-bound", "1");
mapForm.addEventListener("submit", function (ev) {
var rows = document.querySelectorAll("#vspcCompanyMapTbody input[data-company]");
var mappings = [];
rows.forEach(function (inp) {
var company = inp.getAttribute("data-company") || "";
var cname = String(inp.value || "").trim();
if (!company || !cname) return;
var cid = findCustomerIdByName(cname);
if (!cid) return;
mappings.push({ company: company, customer_id: cid });
});
var hidden = document.getElementById("vspc_company_mappings_json");
if (hidden) hidden.value = JSON.stringify(mappings);
});
}
})();
var customerName = meta.customer_name || ""; var customerName = meta.customer_name || "";
var approveForm = document.getElementById("inboxApproveForm"); var approveForm = document.getElementById("inboxApproveForm");

View File

@ -173,6 +173,17 @@
- Fixed a SyntaxError in the Veeam VSPC Active Alarms parser caused by an incomplete regular expression. - Fixed a SyntaxError in the Veeam VSPC Active Alarms parser caused by an incomplete regular expression.
- Restored valid parser loading to prevent Gunicorn startup failure and Bad Gateway errors. - Restored valid parser loading to prevent Gunicorn startup failure and Bad Gateway errors.
- No functional logic changes; fix is limited to syntax correction only. - No functional logic changes; fix is limited to syntax correction only.
---
## v20260112-09-veeam-vspc-company-mapping-popup
- Added a dedicated company mapping popup for Veeam Service Provider Console "Active alarms summary" reports.
- Enabled manual linking of companies found in the mail to existing customers, as automatic customer matching is not applicable for this report type.
- Introduced per-company job and run creation (job name format: "Active alarms summary | <Company>") to ensure correct customer association.
- Ensured only alarms and objects belonging to the selected company are attached to the corresponding run.
- Disabled the standard job approval flow for this report type and replaced it with the company mapping workflow.
================================================================================================================================================ ================================================================================================================================================
## v0.1.19 ## v0.1.19
This release delivers a broad set of improvements focused on reliability, transparency, and operational control across mail processing, administrative auditing, and Run Checks workflows. The changes aim to make message handling more robust, provide better insight for administrators, and give operators clearer and more flexible control when reviewing backup runs. This release delivers a broad set of improvements focused on reliability, transparency, and operational control across mail processing, administrative auditing, and Run Checks workflows. The changes aim to make message handling more robust, provide better insight for administrators, and give operators clearer and more flexible control when reviewing backup runs.