diff --git a/.last-branch b/.last-branch index abb84b7..5c0f3e7 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260112-14-vspc-company-mapping-require-all +v20260112-15-vspc-scroll-partial-approve-objects diff --git a/containers/backupchecks/src/backend/app/main/routes_inbox.py b/containers/backupchecks/src/backend/app/main/routes_inbox.py index e64fa16..7ec40b5 100644 --- a/containers/backupchecks/src/backend/app/main/routes_inbox.py +++ b/containers/backupchecks/src/backend/app/main/routes_inbox.py @@ -488,19 +488,33 @@ def inbox_message_approve_vspc_companies(message_id: int): 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. + mapped_companies = [c for c in companies_present if c in final_map] + + if not mapped_companies: + # Nothing to approve yet; user must map at least one company. 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") + flash( + ( + "Please map at least one company before approving." + + (f" Missing: {missing_str}" if missing_str else "") + ), + "danger", + ) return redirect(url_for("main.inbox")) + def _is_error_status(value: str | None) -> bool: + v = (value or "").strip().lower() + return v in {"error", "failed", "critical"} or v.startswith("fail") + created_runs: list[JobRun] = [] + skipped_existing = 0 first_job: Job | None = None - # Create runs for all companies in the message using the resolved mapping. - for company in companies_present: + # Create runs for mapped companies only. If some companies remain unmapped, + # the message stays in the Inbox so the user can map the remainder later. + for company in mapped_companies: customer_id = int(final_map[company]) customer = Customer.query.get(customer_id) if not customer: @@ -539,23 +553,30 @@ def inbox_message_approve_vspc_companies(message_id: int): .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) + saw_error = any(_is_error_status(o.status) for o in objs) + saw_warning = any((o.status or "").strip().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) + # De-duplicate: do not create multiple runs for the same (mail_message_id, job_id). + run = JobRun.query.filter(JobRun.job_id == job.id, JobRun.mail_message_id == msg.id).first() + if run: + skipped_existing += 1 + else: + 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() + db.session.add(run) + db.session.flush() + created_runs.append(run) + # Persist objects for reporting (idempotent upsert; safe to repeat). try: persist_objects_for_approved_run_filtered( customer.id, @@ -571,13 +592,37 @@ def inbox_message_approve_vspc_companies(message_id: int): 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: + processed_total = len(created_runs) + skipped_existing + if processed_total <= 0: flash("No runs could be created for this VSPC summary.", "danger") return redirect(url_for("main.inbox")) - # Update mail message to reflect approval + # Commit created runs and any job mapping updates first. + 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")) + + if missing_companies: + # Keep message in Inbox until all companies are mapped, but keep the already + # created runs for mapped companies. + missing_str = ", ".join(missing_companies[:10]) + if len(missing_companies) > 10: + missing_str += f" (+{len(missing_companies) - 10} more)" + _log_admin_event( + "inbox_approve_vspc_partial", + f"Partially approved VSPC message {msg.id}: {processed_total} run(s) processed, missing={missing_str}", + ) + flash( + f"Approved {processed_total} mapped compan{'y' if processed_total == 1 else 'ies'}. Message stays in the Inbox until all companies are mapped. Missing: {missing_str}", + "warning", + ) + return redirect(url_for("main.inbox")) + + # All companies mapped: mark the message as approved and move it to History. msg.job_id = first_job.id if first_job else None if hasattr(msg, "approved"): msg.approved = True @@ -592,15 +637,15 @@ def inbox_message_approve_vspc_companies(message_id: int): 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}") + flash("Could not finalize approval due to a database error.", "danger") + _log_admin_event("inbox_approve_error", f"Failed to finalize VSPC approval for 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})", + f"Approved VSPC message {msg.id} into {processed_total} run(s) (job_id={msg.job_id})", ) - flash(f"Approved VSPC summary into {len(created_runs)} run(s).", "success") + flash(f"Approved VSPC summary into {processed_total} run(s).", "success") return redirect(url_for("main.inbox")) diff --git a/containers/backupchecks/src/backend/app/parsers/veeam.py b/containers/backupchecks/src/backend/app/parsers/veeam.py index 81df839..92ee4a9 100644 --- a/containers/backupchecks/src/backend/app/parsers/veeam.py +++ b/containers/backupchecks/src/backend/app/parsers/veeam.py @@ -93,6 +93,8 @@ def _parse_vspc_active_alarms_from_html(html: str) -> Tuple[List[Dict], str, Opt colmap["alarm_name"] = i elif c in {"n. of repeats", "n.of repeats", "repeats"}: colmap["repeats"] = i + elif c in {"alarm details", "alarmdetails", "details"}: + colmap["alarm_details"] = i # Basic validation: needs at least object + current state if "object" not in colmap or "current_state" not in colmap: @@ -118,6 +120,7 @@ def _parse_vspc_active_alarms_from_html(html: str) -> Tuple[List[Dict], str, Opt at_time = plain[colmap.get("time", -1)].strip() if colmap.get("time", -1) >= 0 and colmap.get("time", -1) < len(plain) else "" alarm_name = plain[colmap.get("alarm_name", -1)].strip() if colmap.get("alarm_name", -1) >= 0 and colmap.get("alarm_name", -1) < len(plain) else "" repeats = plain[colmap.get("repeats", -1)].strip() if colmap.get("repeats", -1) >= 0 and colmap.get("repeats", -1) < len(plain) else "" + alarm_details = plain[colmap.get("alarm_details", -1)].strip() if colmap.get("alarm_details", -1) >= 0 and colmap.get("alarm_details", -1) < len(plain) else "" state_lower = (current_state or "").lower() status = "Success" @@ -128,16 +131,19 @@ def _parse_vspc_active_alarms_from_html(html: str) -> Tuple[List[Dict], str, Opt status = "Warning" saw_warning = True - # Try to find a more descriptive detail line in the company text. - detail_line = None + # Prefer the explicit "Alarm Details" column if present. + detail_line = alarm_details or None + + # Otherwise try to find a more descriptive detail line in the company text. # Prefer lines that mention the object or alarm name and are long enough to be a real description. needles = [n for n in [obj_name, alarm_name] if n] - for ln in seg_lines: - if len(ln) < 25: - continue - if any(n.lower() in ln.lower() for n in needles): - detail_line = ln - break + if not detail_line: + for ln in seg_lines: + if len(ln) < 25: + continue + if any(n.lower() in ln.lower() for n in needles): + detail_line = ln + break if not detail_line and alarm_name: # fallback: use alarm name with context diff --git a/containers/backupchecks/src/templates/main/inbox.html b/containers/backupchecks/src/templates/main/inbox.html index a0d69a8..578fe20 100644 --- a/containers/backupchecks/src/templates/main/inbox.html +++ b/containers/backupchecks/src/templates/main/inbox.html @@ -227,7 +227,7 @@ {% endfor %} -