diff --git a/.last-branch b/.last-branch index dad6799..4c130c6 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260115-12-autotask-customers-refreshall-mappings +v20260115-13-autotask-runchecks-create-ticket diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index 6aa9981..4989ec1 100644 --- a/containers/backupchecks/src/backend/app/integrations/autotask/client.py +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -105,7 +105,13 @@ class AutotaskClient: "Accept": "application/json", } - def _request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None) -> Any: + def _request( + self, + method: str, + path: str, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + ) -> Any: zone = self.get_zone_info() base = zone.api_url.rstrip("/") url = f"{base}/v1.0/{path.lstrip('/')}" @@ -120,6 +126,7 @@ class AutotaskClient: url=url, headers=h, params=params or None, + json=json_body if json_body is not None else None, auth=(self.username, self.password) if use_basic_auth else None, timeout=self.timeout_seconds, ) @@ -389,3 +396,30 @@ class AutotaskClient: raise AutotaskError("Tickets.priority metadata did not include picklist values.") return self._call_picklist_values(picklist_values) + + def create_ticket(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Create a Ticket in Autotask. + + Uses POST /Tickets. + Returns the created ticket object (as returned by Autotask). + """ + if not isinstance(payload, dict) or not payload: + raise AutotaskError("Ticket payload is empty.") + + data = self._request("POST", "Tickets", json_body=payload) + # Autotask commonly returns the created object or an items list. + if isinstance(data, dict): + if "item" in data and isinstance(data.get("item"), dict): + return data["item"] + if "items" in data and isinstance(data.get("items"), list) and data.get("items"): + first = data.get("items")[0] + if isinstance(first, dict): + return first + if "id" in data: + return data + # Fallback: return normalized first item if possible + items = self._as_items_list(data) + if items: + return items[0] + + raise AutotaskError("Autotask did not return a created ticket object.") diff --git a/containers/backupchecks/src/backend/app/main/routes_run_checks.py b/containers/backupchecks/src/backend/app/main/routes_run_checks.py index b073af2..b66350c 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -4,7 +4,8 @@ import calendar from datetime import date, datetime, time, timedelta, timezone -from flask import jsonify, render_template, request +from flask import jsonify, render_template, request, url_for +from urllib.parse import urljoin from flask_login import current_user, login_required from sqlalchemy import and_, or_, func, text @@ -35,6 +36,106 @@ from ..models import ( User, ) + +def _build_autotask_client_from_settings(): + """Build an AutotaskClient from stored settings or raise a user-safe exception.""" + settings = _get_or_create_settings() + if not getattr(settings, "autotask_enabled", False): + raise RuntimeError("Autotask integration is disabled.") + + required = [ + getattr(settings, "autotask_environment", None), + getattr(settings, "autotask_api_username", None), + getattr(settings, "autotask_api_password", None), + getattr(settings, "autotask_tracking_identifier", None), + ] + if any(not (x and str(x).strip()) for x in required): + raise RuntimeError("Autotask settings incomplete.") + + from ..integrations.autotask.client import AutotaskClient + + return AutotaskClient( + username=settings.autotask_api_username, + password=settings.autotask_api_password, + api_integration_code=settings.autotask_tracking_identifier, + environment=settings.autotask_environment, + ) + + +def _determine_autotask_severity(status_text: str | None) -> str: + s = (status_text or "").strip().lower() + if "warning" in s: + return "warning" + if "error" in s or "fail" in s: + return "error" + if "missed" in s: + return "error" + return "warning" + + +def _compose_autotask_ticket_description( + *, + settings, + job: Job, + run: JobRun, + status_display: str, + overall_message: str, + objects_payload: list[dict[str, str]], +) -> str: + tz_name = _get_ui_timezone_name() or "Europe/Amsterdam" + run_dt = run.run_at + run_at_str = _format_datetime(run_dt) if run_dt else "-" + + base_url = (getattr(settings, "autotask_base_url", None) or "").strip() + job_rel = url_for("main.job_detail", job_id=job.id) + # Link to Job Details with a hint for the specific run. + job_link = urljoin(base_url.rstrip("/") + "/", job_rel.lstrip("/")) + if run.id: + job_link = f"{job_link}?run_id={int(run.id)}" + + lines: list[str] = [] + lines.append(f"Customer: {job.customer.name if job.customer else ''}") + lines.append(f"Job: {job.job_name or ''}") + lines.append(f"Backup: {job.backup_software or ''} / {job.backup_type or ''}") + lines.append(f"Run at ({tz_name}): {run_at_str}") + lines.append(f"Status: {status_display or ''}") + lines.append("") + + overall_message = (overall_message or "").strip() + if overall_message: + lines.append("Summary:") + lines.append(overall_message) + lines.append("") + lines.append("Multiple objects reported messages. See Backupchecks for full details.") + else: + # Fallback to object-level messages with a hard limit. + limit = 10 + shown = 0 + total = 0 + for o in objects_payload or []: + name = (o.get("name") or "").strip() + err = (o.get("error_message") or "").strip() + st = (o.get("status") or "").strip() + if not name: + continue + if not err and not st: + continue + total += 1 + if shown >= limit: + continue + msg = err or st + lines.append(f"- {name}: {msg}") + shown += 1 + + if total == 0: + lines.append("No detailed object messages available. See Backupchecks for full details.") + elif total > shown: + lines.append(f"And {int(total - shown)} additional objects reported similar messages.") + + lines.append("") + lines.append(f"Backupchecks details: {job_link}") + return "\n".join(lines).strip() + "\n" + # Grace window for matching real runs to an expected schedule slot. # A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot. MISSED_GRACE_WINDOW = timedelta(hours=1) @@ -753,6 +854,8 @@ def run_checks_details(): "mail": mail_meta, "body_html": body_html, "objects": objects_payload, + "autotask_ticket_id": getattr(run, "autotask_ticket_id", None), + "autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "", } ) @@ -770,6 +873,175 @@ def run_checks_details(): return jsonify({"status": "ok", "job": job_payload, "runs": runs_payload}) +@main_bp.post("/api/run-checks/autotask-ticket") +@login_required +@roles_required("admin", "operator") +def api_run_checks_create_autotask_ticket(): + """Create an Autotask ticket for a specific run. + + Enforces: exactly one ticket per run. + """ + data = request.get_json(silent=True) or {} + try: + run_id = int(data.get("run_id") or 0) + except Exception: + run_id = 0 + + if run_id <= 0: + return jsonify({"status": "error", "message": "Invalid parameters."}), 400 + + run = JobRun.query.get(run_id) + if not run: + return jsonify({"status": "error", "message": "Run not found."}), 404 + + # Idempotent: if already created, return existing linkage. + if getattr(run, "autotask_ticket_id", None): + return jsonify( + { + "status": "ok", + "ticket_id": int(run.autotask_ticket_id), + "ticket_number": getattr(run, "autotask_ticket_number", None) or "", + "already_exists": True, + } + ) + + job = Job.query.get(run.job_id) + if not job: + return jsonify({"status": "error", "message": "Job not found."}), 404 + + customer = Customer.query.get(job.customer_id) if getattr(job, "customer_id", None) else None + if not customer: + return jsonify({"status": "error", "message": "Customer not found."}), 404 + + if not getattr(customer, "autotask_company_id", None): + return jsonify({"status": "error", "message": "Customer has no Autotask company mapping."}), 400 + + if (getattr(customer, "autotask_mapping_status", None) or "").strip().lower() not in ("ok", "renamed"): + return jsonify({"status": "error", "message": "Autotask company mapping is not valid."}), 400 + + settings = _get_or_create_settings() + + base_url = (getattr(settings, "autotask_base_url", None) or "").strip() + if not base_url: + return jsonify({"status": "error", "message": "Autotask Base URL is not configured."}), 400 + + # Required ticket defaults + if not getattr(settings, "autotask_default_queue_id", None): + return jsonify({"status": "error", "message": "Autotask default queue is not configured."}), 400 + if not getattr(settings, "autotask_default_ticket_source_id", None): + return jsonify({"status": "error", "message": "Autotask default ticket source is not configured."}), 400 + if not getattr(settings, "autotask_default_ticket_status", None): + return jsonify({"status": "error", "message": "Autotask default ticket status is not configured."}), 400 + + # Determine display status (including overrides) for consistent subject/priority mapping. + status_display = run.status or "-" + try: + status_display, _, _, _ov_id, _ov_reason = _apply_overrides_to_run(job, run) + except Exception: + status_display = run.status or "-" + + severity = _determine_autotask_severity(status_display) + priority_id = None + if severity == "warning": + priority_id = getattr(settings, "autotask_priority_warning", None) + else: + priority_id = getattr(settings, "autotask_priority_error", None) + + # Load mail + objects for ticket composition. + msg = MailMessage.query.get(run.mail_message_id) if run.mail_message_id else None + overall_message = (getattr(msg, "overall_message", None) or "") if msg else "" + + objects_payload: list[dict[str, str]] = [] + try: + objs = run.objects.order_by(JobObject.object_name.asc()).all() + except Exception: + objs = list(run.objects or []) + for o in objs or []: + objects_payload.append( + { + "name": getattr(o, "object_name", "") or "", + "type": getattr(o, "object_type", "") or "", + "status": getattr(o, "status", "") or "", + "error_message": getattr(o, "error_message", "") or "", + } + ) + + if (not objects_payload) and msg: + try: + mos = MailObject.query.filter_by(mail_message_id=msg.id).order_by(MailObject.object_name.asc()).all() + except Exception: + mos = [] + for mo in mos or []: + objects_payload.append( + { + "name": getattr(mo, "object_name", "") or "", + "type": getattr(mo, "object_type", "") or "", + "status": getattr(mo, "status", "") or "", + "error_message": getattr(mo, "error_message", "") or "", + } + ) + + subject = f"[Backupchecks] {customer.name} - {job.job_name or ''} - {status_display}" + description = _compose_autotask_ticket_description( + settings=settings, + job=job, + run=run, + status_display=status_display, + overall_message=overall_message, + objects_payload=objects_payload, + ) + + payload = { + "companyID": int(customer.autotask_company_id), + "title": subject, + "description": description, + "queueID": int(settings.autotask_default_queue_id), + "source": int(settings.autotask_default_ticket_source_id), + "status": int(settings.autotask_default_ticket_status), + } + if priority_id: + payload["priority"] = int(priority_id) + + try: + client = _build_autotask_client_from_settings() + created = client.create_ticket(payload) + except Exception as exc: + return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400 + + ticket_id = created.get("id") if isinstance(created, dict) else None + ticket_number = None + if isinstance(created, dict): + ticket_number = created.get("ticketNumber") or created.get("number") or created.get("ticket_number") + + if not ticket_id: + return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400 + + try: + run.autotask_ticket_id = int(ticket_id) + except Exception: + run.autotask_ticket_id = None + + run.autotask_ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None + run.autotask_ticket_created_at = datetime.utcnow() + run.autotask_ticket_created_by_user_id = current_user.id + + try: + db.session.add(run) + db.session.commit() + except Exception as exc: + db.session.rollback() + return jsonify({"status": "error", "message": f"Failed to store ticket reference: {exc}"}), 500 + + return jsonify( + { + "status": "ok", + "ticket_id": int(run.autotask_ticket_id) if run.autotask_ticket_id else None, + "ticket_number": run.autotask_ticket_number or "", + "already_exists": False, + } + ) + + @main_bp.post("/api/run-checks/mark-reviewed") @login_required @roles_required("admin", "operator") diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 6c21f5e..03e2eea 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -897,6 +897,7 @@ def run_migrations() -> None: migrate_overrides_match_columns() migrate_job_runs_review_tracking() migrate_job_runs_override_metadata() + migrate_job_runs_autotask_ticket_fields() migrate_jobs_archiving() migrate_news_tables() migrate_reporting_tables() @@ -904,6 +905,67 @@ def run_migrations() -> None: print("[migrations] All migrations completed.") +def migrate_job_runs_autotask_ticket_fields() -> None: + """Add Autotask ticket linkage fields to job_runs if missing. + + Columns: + - job_runs.autotask_ticket_id (INTEGER NULL) + - job_runs.autotask_ticket_number (VARCHAR(64) NULL) + - job_runs.autotask_ticket_created_at (TIMESTAMP NULL) + - job_runs.autotask_ticket_created_by_user_id (INTEGER NULL, FK users.id) + """ + + table = "job_runs" + try: + engine = db.get_engine() + except Exception as exc: + print(f"[migrations] Could not get engine for job_runs Autotask ticket migration: {exc}") + return + + try: + with engine.connect() as conn: + cols = _get_table_columns(conn, table) + if not cols: + return + + if "autotask_ticket_id" not in cols: + print("[migrations] Adding job_runs.autotask_ticket_id column...") + conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_id INTEGER')) + + if "autotask_ticket_number" not in cols: + print("[migrations] Adding job_runs.autotask_ticket_number column...") + conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_number VARCHAR(64)')) + + if "autotask_ticket_created_at" not in cols: + print("[migrations] Adding job_runs.autotask_ticket_created_at column...") + conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_created_at TIMESTAMP')) + + if "autotask_ticket_created_by_user_id" not in cols: + print("[migrations] Adding job_runs.autotask_ticket_created_by_user_id column...") + conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_created_by_user_id INTEGER')) + + try: + conn.execute( + text( + 'ALTER TABLE "job_runs" ' + 'ADD CONSTRAINT job_runs_autotask_ticket_created_by_user_id_fkey ' + 'FOREIGN KEY (autotask_ticket_created_by_user_id) REFERENCES users(id) ' + 'ON DELETE SET NULL' + ) + ) + except Exception as exc: + print( + f"[migrations] Could not add FK job_runs.autotask_ticket_created_by_user_id -> users.id (continuing): {exc}" + ) + + conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_id ON "job_runs" (autotask_ticket_id)')) + except Exception as exc: + print(f"[migrations] job_runs table not found; skipping migrate_job_runs_autotask_ticket_fields: {exc}") + return + + print("[migrations] migrate_job_runs_autotask_ticket_fields completed.") + + def migrate_jobs_archiving() -> None: """Add archiving columns to jobs if missing. diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index e72a846..3b3d41b 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -275,6 +275,12 @@ class JobRun(db.Model): reviewed_at = db.Column(db.DateTime, nullable=True) reviewed_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + # Autotask integration (Phase 4: ticket creation from Run Checks) + autotask_ticket_id = db.Column(db.Integer, nullable=True) + autotask_ticket_number = db.Column(db.String(64), nullable=True) + autotask_ticket_created_at = db.Column(db.DateTime, nullable=True) + autotask_ticket_created_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column( @@ -288,6 +294,8 @@ class JobRun(db.Model): reviewed_by = db.relationship("User", foreign_keys=[reviewed_by_user_id]) + autotask_ticket_created_by = db.relationship("User", foreign_keys=[autotask_ticket_created_by_user_id]) + class JobRunReviewEvent(db.Model): __tablename__ = "job_run_review_events" diff --git a/containers/backupchecks/src/templates/main/run_checks.html b/containers/backupchecks/src/templates/main/run_checks.html index 90de7aa..4701bb2 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -214,18 +214,16 @@