Auto-commit local changes before build (2026-01-16 15:38:11)
This commit is contained in:
parent
9025d70b8e
commit
4def0aad46
@ -1 +1 @@
|
||||
v20260116-09-autotask-ticket-propagate-active-runs
|
||||
v20260116-10-autotask-ticket-sync-internal-ticketjobrun
|
||||
|
||||
@ -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,11 +1110,24 @@ def api_run_checks_create_autotask_ticket():
|
||||
|
||||
try:
|
||||
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)
|
||||
except Exception as exc:
|
||||
return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400
|
||||
|
||||
ticket_id = None
|
||||
if isinstance(created, dict):
|
||||
ticket_id = created.get("id") or created.get("itemId") or created.get("ticketId")
|
||||
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
|
||||
|
||||
# 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
|
||||
# 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):
|
||||
@ -1443,7 +1151,7 @@ def api_run_checks_create_autotask_ticket():
|
||||
if isinstance(fetched, dict):
|
||||
ticket_number = fetched.get("ticketNumber") or fetched.get("number") or fetched.get("ticket_number")
|
||||
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.
|
||||
# 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 [],
|
||||
}
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user