|
|
|
|
@ -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
|
|
|
|
|
|