diff --git a/.last-branch b/.last-branch index 3ef23dd..5d34faf 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260116-09-autotask-ticket-propagate-active-runs +v20260116-10-autotask-ticket-sync-internal-ticketjobrun 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 a913900..191867c 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -143,148 +143,6 @@ 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() @@ -671,27 +529,6 @@ 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 @@ -894,14 +731,6 @@ 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 @@ -1171,151 +1000,16 @@ def api_run_checks_create_autotask_ticket(): run = JobRun.query.get(run_id) if not run: return jsonify({"status": "error", "message": "Run not found."}), 404 - # Idempotent: if already created, ensure internal Ticket/JobRun links exist and return existing linkage. - if getattr(run, "autotask_ticket_id", None): - linked_run_ids: list[int] = [] - try: - rows = ( - JobRun.query.filter(JobRun.job_id == run.job_id) - .filter(JobRun.reviewed_at.is_(None)) - .with_entities(JobRun.id) - .order_by(JobRun.id.asc()) - .all() - ) - linked_run_ids = [int(rid) for (rid,) in rows if rid is not None] - except Exception: - linked_run_ids = [] - # Safety: always include the explicitly selected run. - try: - if run.id and int(run.id) not in linked_run_ids: - linked_run_ids.append(int(run.id)) - except Exception: - pass - - # Best-effort: backfill internal Ticket + TicketScope + TicketJobRun links so Tickets/Remarks and Job Details stay consistent. - try: - ticket_code = (getattr(run, "autotask_ticket_number", None) or "").strip().upper() - if ticket_code: - job = Job.query.get(run.job_id) - customer = Customer.query.get(job.customer_id) if job and getattr(job, "customer_id", None) else None - - # Compose a sensible title/description (best-effort). - subject = None - description = None - try: - 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 "-" - - cname = getattr(customer, "name", None) or "" - jname = getattr(job, "job_name", None) or "" - subject = f"[Backupchecks] {cname} - {jname} - {status_display}" - - settings = _get_or_create_settings() - 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 "", - } - ) - - description = _compose_autotask_ticket_description( - settings=settings, - job=job, - run=run, - status_display=status_display, - overall_message=overall_message, - objects_payload=objects_payload, - ) - except Exception: - subject = subject or f"[Backupchecks] Autotask {ticket_code}" - description = description or "" - - internal_ticket = Ticket.query.filter_by(ticket_code=ticket_code).first() - if not internal_ticket: - now = datetime.utcnow() - internal_ticket = Ticket( - ticket_code=ticket_code, - title=subject, - description=description or "", - active_from_date=_to_amsterdam_date(run.run_at) 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 - - # Ensure a job scope exists (used by popups / job details / tickets page) - scope = None - if job and job.id and internal_ticket and internal_ticket.id: - scope = TicketScope.query.filter_by(ticket_id=internal_ticket.id, scope_type="job", job_id=job.id).first() - if not scope and internal_ticket and internal_ticket.id: - scope = TicketScope( - ticket_id=internal_ticket.id, - scope_type="job", - customer_id=job.customer_id if job else None, - backup_software=job.backup_software if job else None, - backup_type=job.backup_type if job else None, - job_id=job.id if job else None, - job_name_match=job.job_name if job else None, - job_name_match_mode="exact", - resolved_at=None, - ) - db.session.add(scope) - elif scope: - scope.resolved_at = None - - # 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")) - - db.session.commit() - except Exception: - db.session.rollback() - - return jsonify( - { - "status": "ok", - "ticket_id": int(run.autotask_ticket_id), - "ticket_number": getattr(run, "autotask_ticket_number", None) or "", - "already_exists": True, - "linked_run_ids": linked_run_ids or [], - } - ) + # Idempotent behavior: + # If the run already has an Autotask ticket linked, we still continue so we can: + # - propagate the linkage to all active (non-reviewed) runs of the same job + # - synchronize the internal Ticket + TicketJobRun records (used by Tickets/Remarks + Job Details) + already_exists = False + existing_ticket_id = getattr(run, "autotask_ticket_id", None) + existing_ticket_number = (getattr(run, "autotask_ticket_number", None) or "").strip() or None + if existing_ticket_id: + already_exists = True job = Job.query.get(run.job_id) if not job: @@ -1416,34 +1110,48 @@ def api_run_checks_create_autotask_ticket(): 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 + return jsonify({"status": "error", "message": f"Autotask client initialization failed: {exc}"}), 400 ticket_id = None - if isinstance(created, dict): - ticket_id = created.get("id") or created.get("itemId") or created.get("ticketId") - try: - # Some wrappers return {"item": {"id": ...}} - if not ticket_id and isinstance(created.get("item"), dict): - ticket_id = created.get("item", {}).get("id") - except Exception: - pass + ticket_number = None - if not ticket_id: - return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400 + if already_exists: + try: + ticket_id = int(existing_ticket_id) + except Exception: + ticket_id = None + ticket_number = existing_ticket_number + else: + try: + created = client.create_ticket(payload) + except Exception as exc: + return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400 + + if isinstance(created, dict): + ticket_id = created.get("id") or created.get("itemId") or created.get("ticketId") + try: + # Some wrappers return {"item": {"id": ...}} + if not ticket_id and isinstance(created.get("item"), dict): + ticket_id = created.get("item", {}).get("id") + except Exception: + pass + + if not ticket_id: + return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400 # Autotask typically does not return the ticket number on create. - # Always fetch the created ticket so we can persist the ticketNumber for UI and internal linking. - ticket_number = None - try: - fetched = client.get_ticket(int(ticket_id)) - if isinstance(fetched, dict) and isinstance(fetched.get("item"), dict): - fetched = fetched.get("item") - if isinstance(fetched, dict): - ticket_number = fetched.get("ticketNumber") or fetched.get("number") or fetched.get("ticket_number") - except Exception: - ticket_number = None + # Also, existing linkages may have ticket_id but no ticket_number yet. + # Always fetch the ticket if we don't have the number so we can persist it for UI and internal linking. + if ticket_id and not ticket_number: + try: + fetched = client.get_ticket(int(ticket_id)) + if isinstance(fetched, dict) and isinstance(fetched.get("item"), dict): + fetched = fetched.get("item") + if isinstance(fetched, dict): + ticket_number = fetched.get("ticketNumber") or fetched.get("number") or fetched.get("ticket_number") + except Exception: + ticket_number = ticket_number or 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 @@ -1502,11 +1210,12 @@ def api_run_checks_create_autotask_ticket(): r.autotask_ticket_created_at = now r.autotask_ticket_created_by_user_id = current_user.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. + # Also store an internal Ticket record and link it to all relevant active runs. + # This keeps Tickets/Remarks, Job Details, and Run Checks indicators consistent with the existing manual workflow, + # and remains functional even if PSA integration is disabled later. internal_ticket = None - if run.autotask_ticket_number: - ticket_code = (run.autotask_ticket_number or "").strip().upper() + if ticket_number: + ticket_code = (str(ticket_number) or "").strip().upper() internal_ticket = Ticket.query.filter_by(ticket_code=ticket_code).first() if not internal_ticket: internal_ticket = Ticket( @@ -1540,7 +1249,7 @@ def api_run_checks_create_autotask_ticket(): elif scope: scope.resolved_at = None - # Link ticket to all relevant job runs (idempotent) + # Link ticket to all relevant active 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")) @@ -1558,7 +1267,7 @@ def api_run_checks_create_autotask_ticket(): "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, + "already_exists": bool(already_exists), "linked_run_ids": linked_run_ids or [], } )