Auto-commit local changes before build (2026-01-16 15:38:11)

This commit is contained in:
Ivo Oskamp 2026-01-16 15:38:11 +01:00
parent 9025d70b8e
commit 4def0aad46
2 changed files with 53 additions and 344 deletions

View File

@ -1 +1 @@
v20260116-09-autotask-ticket-propagate-active-runs v20260116-10-autotask-ticket-sync-internal-ticketjobrun

View File

@ -143,148 +143,6 @@ def _compose_autotask_ticket_description(
# A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot. # A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot.
MISSED_GRACE_WINDOW = timedelta(hours=1) 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: def _status_is_success(status: str | None) -> bool:
s = (status or "").strip().lower() s = (status or "").strip().lower()
@ -671,27 +529,6 @@ def run_checks_page():
rows = q.limit(2000).all() 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. # 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 # The Run Checks modal computes override status on-the-fly, but the overview
# aggregates by persisted JobRun.override_applied. Keep those flags aligned # 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() 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 = [] runs_payload = []
for run in runs: for run in runs:
msg = MailMessage.query.get(run.mail_message_id) if run.mail_message_id else None 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) run = JobRun.query.get(run_id)
if not run: if not run:
return jsonify({"status": "error", "message": "Run not found."}), 404 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. # Idempotent behavior:
try: # If the run already has an Autotask ticket linked, we still continue so we can:
if run.id and int(run.id) not in linked_run_ids: # - propagate the linkage to all active (non-reviewed) runs of the same job
linked_run_ids.append(int(run.id)) # - synchronize the internal Ticket + TicketJobRun records (used by Tickets/Remarks + Job Details)
except Exception: already_exists = False
pass existing_ticket_id = getattr(run, "autotask_ticket_id", None)
existing_ticket_number = (getattr(run, "autotask_ticket_number", None) or "").strip() or None
# Best-effort: backfill internal Ticket + TicketScope + TicketJobRun links so Tickets/Remarks and Job Details stay consistent. if existing_ticket_id:
try: already_exists = True
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 [],
}
)
job = Job.query.get(run.job_id) job = Job.query.get(run.job_id)
if not job: if not job:
@ -1416,11 +1110,24 @@ def api_run_checks_create_autotask_ticket():
try: try:
client = _build_autotask_client_from_settings() client = _build_autotask_client_from_settings()
except Exception as exc:
return jsonify({"status": "error", "message": f"Autotask client initialization failed: {exc}"}), 400
ticket_id = None
ticket_number = None
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) created = client.create_ticket(payload)
except Exception as exc: except Exception as exc:
return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400 return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400
ticket_id = None
if isinstance(created, dict): if isinstance(created, dict):
ticket_id = created.get("id") or created.get("itemId") or created.get("ticketId") ticket_id = created.get("id") or created.get("itemId") or created.get("ticketId")
try: try:
@ -1434,8 +1141,9 @@ def api_run_checks_create_autotask_ticket():
return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400 return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400
# Autotask typically does not return the ticket number on create. # 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. # Also, existing linkages may have ticket_id but no ticket_number yet.
ticket_number = None # 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: try:
fetched = client.get_ticket(int(ticket_id)) fetched = client.get_ticket(int(ticket_id))
if isinstance(fetched, dict) and isinstance(fetched.get("item"), dict): if isinstance(fetched, dict) and isinstance(fetched.get("item"), dict):
@ -1443,7 +1151,7 @@ def api_run_checks_create_autotask_ticket():
if isinstance(fetched, dict): if isinstance(fetched, dict):
ticket_number = fetched.get("ticketNumber") or fetched.get("number") or fetched.get("ticket_number") ticket_number = fetched.get("ticketNumber") or fetched.get("number") or fetched.get("ticket_number")
except Exception: except Exception:
ticket_number = None ticket_number = ticket_number or None
# Link the created Autotask ticket to all relevant open runs of the same job. # 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 # 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_at = now
r.autotask_ticket_created_by_user_id = current_user.id r.autotask_ticket_created_by_user_id = current_user.id
# Also store an internal Ticket record and link it to the run. # 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. # 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 internal_ticket = None
if run.autotask_ticket_number: if ticket_number:
ticket_code = (run.autotask_ticket_number or "").strip().upper() ticket_code = (str(ticket_number) or "").strip().upper()
internal_ticket = Ticket.query.filter_by(ticket_code=ticket_code).first() internal_ticket = Ticket.query.filter_by(ticket_code=ticket_code).first()
if not internal_ticket: if not internal_ticket:
internal_ticket = Ticket( internal_ticket = Ticket(
@ -1540,7 +1249,7 @@ def api_run_checks_create_autotask_ticket():
elif scope: elif scope:
scope.resolved_at = None 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 []: for rid in linked_run_ids or []:
if not TicketJobRun.query.filter_by(ticket_id=internal_ticket.id, job_run_id=rid).first(): 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.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", "status": "ok",
"ticket_id": int(run.autotask_ticket_id) if run.autotask_ticket_id else None, "ticket_id": int(run.autotask_ticket_id) if run.autotask_ticket_id else None,
"ticket_number": run.autotask_ticket_number or "", "ticket_number": run.autotask_ticket_number or "",
"already_exists": False, "already_exists": bool(already_exists),
"linked_run_ids": linked_run_ids or [], "linked_run_ids": linked_run_ids or [],
} }
) )