diff --git a/.last-branch b/.last-branch index a1dfda1..3ef23dd 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260116-08-autotask-ticket-backfill-ticketjobrun +v20260116-09-autotask-ticket-propagate-active-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 61b37d6..a913900 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -143,6 +143,148 @@ def _compose_autotask_ticket_description( # A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot. MISSED_GRACE_WINDOW = timedelta(hours=1) +def _propagate_autotask_ticket_to_active_runs(job_id: int) -> None: + """Ensure Autotask ticket linkage is consistent across all active (unreviewed) runs of a job. + + Rules: + - Only affects runs where reviewed_at is NULL (i.e. visible as active on Run Checks). + - If an active Autotask ticket exists on any run for this job, copy it to all other active runs. + - Keep internal Ticket + TicketScope + TicketJobRun relations in sync so Tickets/Remarks and Job Details stay consistent. + - Do nothing if no active ticket exists. + + Best-effort: errors must never break page load. + """ + + try: + # Find a "source" run with an Autotask ticket among active runs. + source = ( + JobRun.query.filter(JobRun.job_id == job_id) + .filter(JobRun.reviewed_at.is_(None)) + .filter(JobRun.autotask_ticket_id.isnot(None)) + .order_by(func.coalesce(JobRun.run_at, JobRun.created_at).desc(), JobRun.id.desc()) + .first() + ) + if not source: + return + + try: + ticket_id = int(getattr(source, "autotask_ticket_id", None) or 0) + except Exception: + ticket_id = 0 + if ticket_id <= 0: + return + + ticket_number = (getattr(source, "autotask_ticket_number", None) or "").strip() or None + + # Collect all active runs for this job. + active_runs = ( + JobRun.query.filter(JobRun.job_id == job_id) + .filter(JobRun.reviewed_at.is_(None)) + .order_by(func.coalesce(JobRun.run_at, JobRun.created_at).desc(), JobRun.id.desc()) + .all() + ) + if not active_runs: + return + + # Update run fields (but never overwrite a different ticket). + changed = False + now = datetime.utcnow() + for r in active_runs: + existing = getattr(r, "autotask_ticket_id", None) + if existing is not None: + try: + if int(existing) != int(ticket_id): + continue + except Exception: + continue + + if getattr(r, "autotask_ticket_id", None) is None: + try: + r.autotask_ticket_id = int(ticket_id) + changed = True + except Exception: + pass + + # Always propagate ticket number if we have one and the run doesn't. + if ticket_number and not (getattr(r, "autotask_ticket_number", None) or "").strip(): + r.autotask_ticket_number = ticket_number + changed = True + + # Preserve created-by metadata if already present; otherwise best-effort fill. + if getattr(r, "autotask_ticket_created_at", None) is None: + r.autotask_ticket_created_at = now + changed = True + + # Ensure internal ticket linkage when we have a ticket number. + if ticket_number: + ticket_code = ticket_number.strip().upper() + job = Job.query.get(job_id) + if job: + internal_ticket = Ticket.query.filter_by(ticket_code=ticket_code).first() + if not internal_ticket: + # Minimal record; title/description may be refined elsewhere. + internal_ticket = Ticket( + ticket_code=ticket_code, + title=f"[Backupchecks] Autotask {ticket_code}", + description="", + active_from_date=_to_amsterdam_date(getattr(source, "run_at", None)) or _to_amsterdam_date(now) or now.date(), + start_date=now, + resolved_at=None, + ) + db.session.add(internal_ticket) + db.session.flush() + else: + # Keep it active if it was previously resolved globally. + if internal_ticket.resolved_at is not None: + internal_ticket.resolved_at = None + + + # If this ticket is already resolved for this job (or globally), do not propagate it to new runs. + if internal_ticket.resolved_at is not None: + return + + existing_scope = TicketScope.query.filter_by(ticket_id=internal_ticket.id, scope_type="job", job_id=job.id).first() + if existing_scope and existing_scope.resolved_at is not None: + return + + # Ensure a job scope exists. + scope = existing_scope + if not scope: + scope = TicketScope( + ticket_id=internal_ticket.id, + scope_type="job", + customer_id=job.customer_id, + backup_software=job.backup_software, + backup_type=job.backup_type, + job_id=job.id, + job_name_match=job.job_name, + job_name_match_mode="exact", + resolved_at=None, + ) + db.session.add(scope) + else: + scope.resolved_at = None + + # Link ticket to all active job runs (idempotent). + for r in active_runs: + if not TicketJobRun.query.filter_by(ticket_id=internal_ticket.id, job_run_id=r.id).first(): + db.session.add(TicketJobRun(ticket_id=internal_ticket.id, job_run_id=r.id, link_source="autotask")) + + if changed: + for r in active_runs: + db.session.add(r) + + if changed or ticket_number: + db.session.commit() + + except Exception: + try: + db.session.rollback() + except Exception: + pass + return + + def _status_is_success(status: str | None) -> bool: s = (status or "").strip().lower() @@ -529,6 +671,27 @@ def run_checks_page(): rows = q.limit(2000).all() + # Ensure Autotask tickets remain visible on all active runs until the ticket is resolved. + # This also guarantees that newly arrived runs inherit the existing ticket while it is still open. + try: + job_ids_for_page = [int(r.job_id) for r in rows] + if job_ids_for_page and not include_reviewed: + jobs_with_autotask = ( + db.session.query(JobRun.job_id) + .filter(JobRun.job_id.in_(job_ids_for_page)) + .filter(JobRun.reviewed_at.is_(None)) + .filter(JobRun.autotask_ticket_id.isnot(None)) + .group_by(JobRun.job_id) + .all() + ) + for (jid,) in jobs_with_autotask or []: + try: + _propagate_autotask_ticket_to_active_runs(int(jid)) + except Exception: + pass + except Exception: + pass + # Ensure override flags are up-to-date for the runs shown in this overview. # The Run Checks modal computes override status on-the-fly, but the overview # aggregates by persisted JobRun.override_applied. Keep those flags aligned @@ -731,6 +894,14 @@ def run_checks_details(): runs = q.order_by(func.coalesce(JobRun.run_at, JobRun.created_at).desc(), JobRun.id.desc()).limit(400).all() + # Ensure Autotask tickets are consistently linked to all active runs of this job (until resolved). + if not include_reviewed: + _propagate_autotask_ticket_to_active_runs(job.id) + # Re-load runs so the response reflects any propagated ticket fields immediately. + q2 = JobRun.query.filter(JobRun.job_id == job.id) + q2 = q2.filter(JobRun.reviewed_at.is_(None)) + runs = q2.order_by(func.coalesce(JobRun.run_at, JobRun.created_at).desc(), JobRun.id.desc()).limit(400).all() + runs_payload = [] for run in runs: msg = MailMessage.query.get(run.mail_message_id) if run.mail_message_id else None diff --git a/docs/changelog.md b/docs/changelog.md index 957a184..639d00d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -197,6 +197,14 @@ Changes: - Corrected Job Details visibility so open runs linked to the same ticket now display the ticket number consistently. - Aligned Run Checks, Tickets, and Job Details views to use the same ticket-jobrun linkage logic. +## v20260116-09-autotask-ticket-propagate-active-runs + +- Updated ticket propagation logic so Autotask tickets are linked to all active job runs (non-Reviewed) visible on the Run Checks page. +- Ensured ticket remarks and ticket-jobrun entries are created for each active run, not only the initially selected run. +- Implemented automatic ticket inheritance for newly incoming runs of the same job while the ticket remains unresolved. +- Stopped ticket propagation once the ticket or job is marked as Resolved to prevent incorrect linking to closed incidents. +- Aligned Run Checks, Tickets overview, and Job Details to consistently reflect ticket presence across all active runs. + *** ## v0.1.21