Auto-commit local changes before build (2026-01-12 14:28:50) #101

Merged
ivooskamp merged 1 commits from v20260112-14-vspc-company-mapping-require-all into main 2026-01-13 11:46:14 +01:00
4 changed files with 192 additions and 31 deletions
Showing only changes of commit f18044f72c - Show all commits

View File

@ -1 +1 @@
v20260112-13-vspc-company-mapping-popup-ui v20260112-14-vspc-company-mapping-require-all

View File

@ -7,6 +7,45 @@ import time
import re import re
import html as _html import html as _html
def _extract_vspc_active_alarms_companies(raw: str) -> list[str]:
"""Best-effort extraction of company names from VSPC "Active alarms summary" bodies.
Only returns companies with alarms > 0, as the email is expected to list failing customers.
"""
if not raw:
return []
txt = raw
# Best-effort HTML to text
if "<" in txt and ">" in txt:
txt = re.sub(r"<[^>]+>", " ", txt)
txt = _html.unescape(txt)
txt = re.sub(r"\s+", " ", txt).strip()
seen: set[str] = set()
out: list[str] = []
for m in re.finditer(
r"\bCompany:\s*([^\(\r\n]+?)\s*\(\s*alarms?\s*:\s*(\d+)\s*\)",
txt,
flags=re.IGNORECASE,
):
cname = (m.group(1) or "").strip()
try:
alarms = int(m.group(2))
except Exception:
alarms = 0
if not cname or alarms <= 0:
continue
if cname in seen:
continue
seen.add(cname)
out.append(cname)
return out
@main_bp.route("/inbox") @main_bp.route("/inbox")
@login_required @login_required
@roles_required("admin", "operator", "viewer") @roles_required("admin", "operator", "viewer")
@ -154,6 +193,7 @@ def inbox_message_detail(message_id: int):
# VSPC multi-company emails (e.g. "Active alarms summary") may not store parsed objects yet. # VSPC multi-company emails (e.g. "Active alarms summary") may not store parsed objects yet.
# Extract company names from the stored body so the UI can offer a dedicated mapping workflow. # Extract company names from the stored body so the UI can offer a dedicated mapping workflow.
vspc_companies: list[str] = [] vspc_companies: list[str] = []
vspc_company_defaults: dict[str, dict] = {}
try: try:
bsw = (getattr(msg, "backup_software", "") or "").strip().lower() bsw = (getattr(msg, "backup_software", "") or "").strip().lower()
btype = (getattr(msg, "backup_type", "") or "").strip().lower() btype = (getattr(msg, "backup_type", "") or "").strip().lower()
@ -161,25 +201,41 @@ def inbox_message_detail(message_id: int):
if bsw == "veeam" and btype == "service provider console" and jname == "active alarms summary": if bsw == "veeam" and btype == "service provider console" and jname == "active alarms summary":
raw = text_body if not _is_blank_text(text_body) else (html_body or "") raw = text_body if not _is_blank_text(text_body) else (html_body or "")
if raw: vspc_companies = _extract_vspc_active_alarms_companies(raw)
txt = raw
# Best-effort HTML to text
if "<" in txt and ">" in txt:
txt = re.sub(r"<[^>]+>", " ", txt)
txt = _html.unescape(txt)
txt = re.sub(r"\s+", " ", txt).strip()
seen = set() # For each company, prefill the UI with the existing customer mapping if we already have a job for it.
for m2 in re.finditer(r"\bCompany:\s*([^\(\r\n]+?)\s*\(\s*alarms?\s*:", txt, flags=re.IGNORECASE): # This avoids re-mapping known companies and keeps the message actionable in the Inbox.
cname = (m2.group(1) or "").strip() if vspc_companies:
if cname and cname not in seen: for company in vspc_companies:
seen.add(cname) norm_from, store_backup, store_type, _store_job = build_job_match_key(msg)
vspc_companies.append(cname) 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 and getattr(job, "customer_id", None):
c = Customer.query.get(int(job.customer_id))
if c:
vspc_company_defaults[company] = {
"customer_id": int(c.id),
"customer_name": c.name,
}
except Exception: except Exception:
vspc_companies = [] vspc_companies = []
vspc_company_defaults = {}
return jsonify({"status": "ok", "meta": meta, "body_html": body_html, "objects": objects, "vspc_companies": vspc_companies}) return jsonify({
"status": "ok",
"meta": meta,
"body_html": body_html,
"objects": objects,
"vspc_companies": vspc_companies,
"vspc_company_defaults": vspc_company_defaults,
})
@main_bp.route("/inbox/message/<int:message_id>/eml") @main_bp.route("/inbox/message/<int:message_id>/eml")
@ -324,18 +380,17 @@ def inbox_message_approve_vspc_companies(message_id: int):
return redirect(url_for("main.inbox")) return redirect(url_for("main.inbox"))
mappings_json = (request.form.get("company_mappings_json") or "").strip() 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: try:
mappings = json.loads(mappings_json) mappings = json.loads(mappings_json) if mappings_json else []
except Exception: except Exception:
flash("Invalid company mappings payload.", "danger") flash("Invalid company mappings payload.", "danger")
return redirect(url_for("main.inbox")) return redirect(url_for("main.inbox"))
if not isinstance(mappings, list) or not mappings: if mappings is None:
flash("Please map at least one company before approving.", "danger") mappings = []
if not isinstance(mappings, list):
flash("Invalid company mappings payload.", "danger")
return redirect(url_for("main.inbox")) return redirect(url_for("main.inbox"))
# Validate message type (best-effort guard) # Validate message type (best-effort guard)
@ -343,9 +398,36 @@ def inbox_message_approve_vspc_companies(message_id: int):
flash("This approval method is only valid for Veeam Service Provider Console summary emails.", "danger") flash("This approval method is only valid for Veeam Service Provider Console summary emails.", "danger")
return redirect(url_for("main.inbox")) return redirect(url_for("main.inbox"))
created_runs: list[JobRun] = [] # Determine companies present in this message (only alarms > 0).
first_job: Job | None = None html_body = getattr(msg, "html_body", None)
text_body = getattr(msg, "text_body", None)
raw_for_companies = text_body if (text_body and str(text_body).strip()) else (html_body or "")
companies_present = _extract_vspc_active_alarms_companies(raw_for_companies)
if not companies_present:
flash("No companies could be detected in this VSPC summary email.", "danger")
return redirect(url_for("main.inbox"))
# Resolve existing mappings from already-created per-company jobs.
existing_map: dict[str, int] = {}
for company in companies_present:
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 and getattr(job, "customer_id", None):
try:
existing_map[company] = int(job.customer_id)
except Exception:
pass
# Resolve mappings provided by the user from the popup.
provided_map: dict[str, int] = {}
for item in mappings: for item in mappings:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
@ -363,7 +445,10 @@ def inbox_message_approve_vspc_companies(message_id: int):
if not customer: if not customer:
continue continue
# Build / find job for this specific company provided_map[company] = int(customer.id)
# Persist mapping immediately by creating/updating the per-company job.
# This ensures already mapped companies are shown next time, even if approval is blocked.
norm_from, store_backup, store_type, _store_job = build_job_match_key(msg) 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() company_job_name = f"{(msg.job_name or 'Active alarms summary').strip()} | {company}".strip()
@ -375,7 +460,62 @@ def inbox_message_approve_vspc_companies(message_id: int):
) )
job = find_matching_job(tmp_msg) job = find_matching_job(tmp_msg)
if job: 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()
# Commit any mapping updates so they are visible immediately in the UI.
try:
db.session.commit()
except Exception:
db.session.rollback()
flash("Could not save company mappings due to a database error.", "danger")
return redirect(url_for("main.inbox"))
# Final mapping resolution: existing job mappings + any newly provided ones.
final_map: dict[str, int] = dict(existing_map)
final_map.update(provided_map)
missing_companies = [c for c in companies_present if c not in final_map]
if missing_companies:
# Keep message in Inbox until all companies are mapped.
missing_str = ", ".join(missing_companies[:10])
if len(missing_companies) > 10:
missing_str += f" (+{len(missing_companies) - 10} more)"
flash(f"Please map all companies before approving. Missing: {missing_str}", "danger")
return redirect(url_for("main.inbox"))
created_runs: list[JobRun] = []
first_job: Job | None = None
# Create runs for all companies in the message using the resolved mapping.
for company in companies_present:
customer_id = int(final_map[company])
customer = Customer.query.get(customer_id)
if not customer:
continue
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:
if job.customer_id != customer.id: if job.customer_id != customer.id:
job.customer_id = customer.id job.customer_id = customer.id
else: else:
@ -394,7 +534,6 @@ def inbox_message_approve_vspc_companies(message_id: int):
if not first_job: if not first_job:
first_job = job first_job = job
# Derive per-company status from the mail objects
objs = ( objs = (
MailObject.query.filter(MailObject.mail_message_id == msg.id) MailObject.query.filter(MailObject.mail_message_id == msg.id)
.filter(MailObject.object_name.like(f"{company} | %")) .filter(MailObject.object_name.like(f"{company} | %"))
@ -415,11 +554,17 @@ def inbox_message_approve_vspc_companies(message_id: int):
run.remark = getattr(msg, "overall_message", None) run.remark = getattr(msg, "overall_message", None)
db.session.add(run) db.session.add(run)
db.session.flush() # ensure run.id db.session.flush()
# Persist only objects belonging to this company into the customer's object space
try: try:
persist_objects_for_approved_run_filtered(customer.id, job.id, run.id, msg.id, object_name_prefix=company, strip_prefix=True) 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: except Exception as exc:
_log_admin_event( _log_admin_event(
"object_persist_error", "object_persist_error",
@ -429,7 +574,7 @@ def inbox_message_approve_vspc_companies(message_id: int):
created_runs.append(run) created_runs.append(run)
if not created_runs: if not created_runs:
flash("No valid company/customer mappings were provided.", "danger") flash("No runs could be created for this VSPC summary.", "danger")
return redirect(url_for("main.inbox")) return redirect(url_for("main.inbox"))
# Update mail message to reflect approval # Update mail message to reflect approval

View File

@ -524,6 +524,7 @@ function findCustomerIdByName(name) {
} }
var companies = (data.vspc_companies || meta.vspc_companies || []); var companies = (data.vspc_companies || meta.vspc_companies || []);
var defaults = (data.vspc_company_defaults || {});
if (!Array.isArray(companies)) companies = []; if (!Array.isArray(companies)) companies = [];
// Fallback for older stored messages where companies were embedded in object names. // Fallback for older stored messages where companies were embedded in object names.
@ -576,6 +577,14 @@ function findCustomerIdByName(name) {
inp.setAttribute("list", "vspcCustomerList"); inp.setAttribute("list", "vspcCustomerList");
inp.setAttribute("data-company", company); inp.setAttribute("data-company", company);
inp.placeholder = "Select customer"; inp.placeholder = "Select customer";
// Prefill with existing mapping when available.
try {
var d = defaults && defaults[company];
if (d && d.customer_name) {
inp.value = String(d.customer_name);
}
} catch (e) {}
tdS.appendChild(inp); tdS.appendChild(inp);
tr.appendChild(tdS); tr.appendChild(tdS);

View File

@ -213,6 +213,13 @@
- Updated the Inbox message modal to show the “Map companies” button based on the returned VSPC company list (with fallback to legacy object-name parsing). - Updated the Inbox message modal to show the “Map companies” button based on the returned VSPC company list (with fallback to legacy object-name parsing).
- Disabled the standard Customer selector when VSPC company mapping is available to avoid using the wrong approval flow. - Disabled the standard Customer selector when VSPC company mapping is available to avoid using the wrong approval flow.
---
## v20260112-14-vspc-company-mapping-require-all
- Filtered VSPC company extraction to include only companies with alarms > 0 to avoid showing unrelated companies in the mapping popup.
- Added default customer prefill for each VSPC company based on existing per-company jobs (previous mappings).
- Changed VSPC approval flow to require all companies in the email to be mapped before approval; when mappings are incomplete the message stays in the Inbox while newly provided mappings are still saved.
================================================================================================================================================ ================================================================================================================================================
## 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.