Auto-commit local changes before build (2026-01-12 14:28:50)
This commit is contained in:
parent
b791c43299
commit
f18044f72c
@ -1 +1 @@
|
||||
v20260112-13-vspc-company-mapping-popup-ui
|
||||
v20260112-14-vspc-company-mapping-require-all
|
||||
|
||||
@ -7,6 +7,45 @@ import time
|
||||
import re
|
||||
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")
|
||||
@login_required
|
||||
@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.
|
||||
# Extract company names from the stored body so the UI can offer a dedicated mapping workflow.
|
||||
vspc_companies: list[str] = []
|
||||
vspc_company_defaults: dict[str, dict] = {}
|
||||
try:
|
||||
bsw = (getattr(msg, "backup_software", "") 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":
|
||||
raw = text_body if not _is_blank_text(text_body) else (html_body or "")
|
||||
if 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()
|
||||
vspc_companies = _extract_vspc_active_alarms_companies(raw)
|
||||
|
||||
seen = set()
|
||||
for m2 in re.finditer(r"\bCompany:\s*([^\(\r\n]+?)\s*\(\s*alarms?\s*:", txt, flags=re.IGNORECASE):
|
||||
cname = (m2.group(1) or "").strip()
|
||||
if cname and cname not in seen:
|
||||
seen.add(cname)
|
||||
vspc_companies.append(cname)
|
||||
# For each company, prefill the UI with the existing customer mapping if we already have a job for it.
|
||||
# This avoids re-mapping known companies and keeps the message actionable in the Inbox.
|
||||
if vspc_companies:
|
||||
for company in vspc_companies:
|
||||
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):
|
||||
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:
|
||||
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")
|
||||
@ -324,18 +380,17 @@ def inbox_message_approve_vspc_companies(message_id: int):
|
||||
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)
|
||||
mappings = json.loads(mappings_json) if mappings_json else []
|
||||
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")
|
||||
if mappings is None:
|
||||
mappings = []
|
||||
|
||||
if not isinstance(mappings, list):
|
||||
flash("Invalid company mappings payload.", "danger")
|
||||
return redirect(url_for("main.inbox"))
|
||||
|
||||
# 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")
|
||||
return redirect(url_for("main.inbox"))
|
||||
|
||||
created_runs: list[JobRun] = []
|
||||
first_job: Job | None = None
|
||||
# Determine companies present in this message (only alarms > 0).
|
||||
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:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
@ -363,7 +445,10 @@ def inbox_message_approve_vspc_companies(message_id: int):
|
||||
if not customer:
|
||||
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)
|
||||
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)
|
||||
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:
|
||||
job.customer_id = customer.id
|
||||
else:
|
||||
@ -394,7 +534,6 @@ def inbox_message_approve_vspc_companies(message_id: int):
|
||||
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} | %"))
|
||||
@ -415,11 +554,17 @@ def inbox_message_approve_vspc_companies(message_id: int):
|
||||
run.remark = getattr(msg, "overall_message", None)
|
||||
|
||||
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:
|
||||
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:
|
||||
_log_admin_event(
|
||||
"object_persist_error",
|
||||
@ -429,7 +574,7 @@ def inbox_message_approve_vspc_companies(message_id: int):
|
||||
created_runs.append(run)
|
||||
|
||||
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"))
|
||||
|
||||
# Update mail message to reflect approval
|
||||
|
||||
@ -524,6 +524,7 @@ function findCustomerIdByName(name) {
|
||||
}
|
||||
|
||||
var companies = (data.vspc_companies || meta.vspc_companies || []);
|
||||
var defaults = (data.vspc_company_defaults || {});
|
||||
if (!Array.isArray(companies)) companies = [];
|
||||
|
||||
// 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("data-company", company);
|
||||
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);
|
||||
tr.appendChild(tdS);
|
||||
|
||||
|
||||
@ -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).
|
||||
- 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
|
||||
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