diff --git a/.last-branch b/.last-branch index bb1550c..82dca39 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260116-04-runchecks-autotask-ticket-polling +v20260116-05-autotask-ticket-create-link-all-open-runs 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 9b2c06d..c566f54 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -876,110 +876,6 @@ def run_checks_details(): return jsonify({"status": "ok", "job": job_payload, "runs": runs_payload}) - - -@main_bp.get("/api/run-checks/autotask-ticket-poll") -@login_required -@roles_required("admin", "operator") -def api_run_checks_autotask_ticket_poll(): - """Read-only polling of Autotask ticket state for Run Checks. - - Important: - - No Backupchecks state is modified. - - No mutations are performed in Autotask. - - This endpoint is intended to be called only from the Run Checks page. - """ - - include_reviewed = False - if get_active_role() == "admin": - include_reviewed = request.args.get("include_reviewed", "0") in ("1", "true", "yes", "on") - - # Only consider recently relevant runs to keep the payload small. - # We intentionally avoid unbounded history polling. - days = 60 - try: - days = int(request.args.get("days", "60")) - except Exception: - days = 60 - if days < 1: - days = 1 - if days > 180: - days = 180 - - now_utc = datetime.utcnow().replace(tzinfo=None) - window_start = now_utc - timedelta(days=days) - - q = JobRun.query.filter(JobRun.autotask_ticket_id.isnot(None)) - if not include_reviewed: - q = q.filter(JobRun.reviewed_at.is_(None)) - - # Only poll runs in our time window. - q = q.filter(func.coalesce(JobRun.run_at, JobRun.created_at) >= window_start) - - runs = ( - q.order_by(func.coalesce(JobRun.run_at, JobRun.created_at).desc(), JobRun.id.desc()) - .limit(400) - .all() - ) - - ticket_ids = [] - seen = set() - for r in runs: - tid = getattr(r, "autotask_ticket_id", None) - try: - tid_int = int(tid) - except Exception: - continue - if tid_int <= 0 or tid_int in seen: - continue - seen.add(tid_int) - ticket_ids.append(tid_int) - - if not ticket_ids: - return jsonify({"status": "ok", "tickets": []}) - - # If integration is disabled, do not fail the page. - settings = _get_or_create_settings() - if not getattr(settings, "autotask_enabled", False): - return jsonify({"status": "ok", "tickets": [], "autotask_enabled": False}) - - try: - client = _build_autotask_client_from_settings() - except Exception as exc: - return jsonify({"status": "ok", "tickets": [], "autotask_enabled": True, "message": str(exc)}) - - corr_id = datetime.utcnow().strftime("rcpoll-%Y%m%d%H%M%S") - - # Query tickets in Autotask (best-effort) - tickets = [] - try: - tickets = client.query_tickets_by_ids(ticket_ids, corr_id=corr_id) - except Exception: - tickets = [] - - # Build a minimal payload for UI use. - out = [] - for t in tickets or []: - if not isinstance(t, dict): - continue - tid = t.get("id") - try: - tid_int = int(tid) - except Exception: - continue - - out.append( - { - "id": tid_int, - "ticketNumber": (t.get("ticketNumber") or t.get("TicketNumber") or "") or "", - "status": t.get("status"), - "statusName": (t.get("statusName") or t.get("StatusName") or "") or "", - "title": (t.get("title") or t.get("Title") or "") or "", - "lastActivityDate": (t.get("lastActivityDate") or t.get("LastActivityDate") or t.get("lastActivity") or "") or "", - } - ) - - return jsonify({"status": "ok", "tickets": out, "autotask_enabled": True}) @main_bp.post("/api/run-checks/autotask-ticket") @login_required @roles_required("admin", "operator") @@ -1140,15 +1036,43 @@ def api_run_checks_create_autotask_ticket(): except Exception: ticket_number = None - try: - run.autotask_ticket_id = int(ticket_id) - except Exception: - run.autotask_ticket_id = None - + # Link the created Autotask ticket to all relevant open runs of the same job. + # This matches the manual ticket workflow where one ticket remains visible across runs + # until it is explicitly resolved. now = datetime.utcnow() - run.autotask_ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None - run.autotask_ticket_created_at = now - run.autotask_ticket_created_by_user_id = current_user.id + + linked_run_ids: list[int] = [] + try: + open_runs = ( + JobRun.query.filter(JobRun.job_id == run.job_id) + .filter(JobRun.reviewed_at.is_(None)) + .order_by(JobRun.id.asc()) + .all() + ) + except Exception: + open_runs = [run] + + # Safety: always include the explicitly selected run. + if run not in (open_runs or []): + open_runs = (open_runs or []) + [run] + + for r in open_runs or []: + # Do not overwrite an existing (different) ticket linkage. + existing_id = getattr(r, "autotask_ticket_id", None) + if existing_id and int(existing_id) != int(ticket_id): + continue + + try: + r.autotask_ticket_id = int(ticket_id) + except Exception: + r.autotask_ticket_id = None + + r.autotask_ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None + r.autotask_ticket_created_at = now + r.autotask_ticket_created_by_user_id = current_user.id + + if getattr(r, "id", None): + linked_run_ids.append(int(r.id)) # Also store an internal Ticket record and link it to the run. # This keeps Tickets/Remarks, Job Details, and Run Checks indicators consistent with the existing manual workflow. @@ -1188,13 +1112,14 @@ def api_run_checks_create_autotask_ticket(): elif scope: scope.resolved_at = None - # Link ticket to this job run (idempotent) - if internal_ticket and internal_ticket.id and run.id: - if not TicketJobRun.query.filter_by(ticket_id=internal_ticket.id, job_run_id=run.id).first(): - db.session.add(TicketJobRun(ticket_id=internal_ticket.id, job_run_id=run.id, link_source="autotask")) + # Link ticket to all relevant job runs (idempotent) + for rid in linked_run_ids or []: + if not TicketJobRun.query.filter_by(ticket_id=internal_ticket.id, job_run_id=rid).first(): + db.session.add(TicketJobRun(ticket_id=internal_ticket.id, job_run_id=rid, link_source="autotask")) try: - db.session.add(run) + for r in open_runs or []: + db.session.add(r) db.session.commit() except Exception as exc: db.session.rollback() diff --git a/docs/changelog.md b/docs/changelog.md index 9ed55d6..a13b157 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -163,6 +163,15 @@ Changes: - Updated Run Checks UI to display polled PSA ticket status without modifying run state - Explicitly prevented any ticket mutation, resolution, or Backupchecks state changes +## v20260116-05-autotask-ticket-create-link-all-open-runs + +### Changes: +- Fixed Autotask ticket creation to link the newly created ticket to all relevant open runs of the same job +- Aligned automatic ticket creation behaviour with existing manual ticket linking logic +- Ensured ticket linkage is applied consistently across runs until the ticket is resolved +- Prevented Phase 2.1 polling from being blocked by incomplete ticket-run associations +- No changes made to polling logic, resolution logic, or PSA state interpretation + *** ## v0.1.21