diff --git a/.last-branch b/.last-branch index ea6eea4..14aa591 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260112-08-fix-veeam-vspc-parser-syntaxerror +v20260112-09-veeam-vspc-company-mapping-popup diff --git a/containers/backupchecks/src/backend/app/main/routes_inbox.py b/containers/backupchecks/src/backend/app/main/routes_inbox.py index 7caee45..fbbdfa7 100644 --- a/containers/backupchecks/src/backend/app/main/routes_inbox.py +++ b/containers/backupchecks/src/backend/app/main/routes_inbox.py @@ -285,6 +285,155 @@ def inbox_message_approve(message_id: int): @main_bp.route("/inbox/message//delete", methods=["POST"]) @login_required @roles_required("admin", "operator") + + +@main_bp.route("/inbox//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) diff --git a/containers/backupchecks/src/backend/app/object_persistence.py b/containers/backupchecks/src/backend/app/object_persistence.py index 0113992..1d60178 100644 --- a/containers/backupchecks/src/backend/app/object_persistence.py +++ b/containers/backupchecks/src/backend/app/object_persistence.py @@ -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 (" | "). + strip_prefix: If True, store object names without the " | " 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 + diff --git a/containers/backupchecks/src/templates/main/inbox.html b/containers/backupchecks/src/templates/main/inbox.html index 11a8dd9..da280e7 100644 --- a/containers/backupchecks/src/templates/main/inbox.html +++ b/containers/backupchecks/src/templates/main/inbox.html @@ -195,7 +195,8 @@ {% if current_user.is_authenticated and active_role in ["admin", "operator"] %}
- + +
@@ -207,6 +208,50 @@ + + +
+