Auto-commit local changes before build (2026-01-12 13:32:27)
This commit is contained in:
parent
e84e42d856
commit
0d8f4e88e6
@ -1 +1 @@
|
||||
v20260112-08-fix-veeam-vspc-parser-syntaxerror
|
||||
v20260112-09-veeam-vspc-company-mapping-popup
|
||||
|
||||
@ -285,6 +285,155 @@ def inbox_message_approve(message_id: int):
|
||||
@main_bp.route("/inbox/message/<int:message_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
@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):
|
||||
msg = MailMessage.query.get_or_404(message_id)
|
||||
|
||||
|
||||
@ -130,3 +130,127 @@ def persist_objects_for_approved_run(customer_id: int, job_id: int, run_id: int,
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -195,7 +195,8 @@
|
||||
{% if current_user.is_authenticated and active_role in ["admin", "operator"] %}
|
||||
<form id="inboxApproveForm" method="POST" action="" class="me-auto mb-0">
|
||||
<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 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>
|
||||
@ -207,6 +208,50 @@
|
||||
</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>
|
||||
(function () {
|
||||
var customers = {{ customers|tojson|safe }};
|
||||
@ -455,6 +500,107 @@ function findCustomerIdByName(name) {
|
||||
|
||||
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 approveForm = document.getElementById("inboxApproveForm");
|
||||
|
||||
|
||||
@ -173,6 +173,17 @@
|
||||
- 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.
|
||||
- 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
|
||||
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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user