Auto-commit local changes before build (2026-01-20 08:49:15)

This commit is contained in:
Ivo Oskamp 2026-01-20 08:49:15 +01:00
parent 63526be592
commit 5131d24751
6 changed files with 177 additions and 6 deletions

View File

@ -1 +1 @@
v20260119-18-fix-legacy-ticketnumber-sync
v20260120-01-autotask-deleted-ticket-detection

View File

@ -483,6 +483,37 @@ class AutotaskClient:
raise AutotaskError("Autotask did not return a ticket object.")
def query_deleted_ticket_logs_by_ticket_ids(self, ticket_ids: List[int]) -> List[Dict[str, Any]]:
"""Query DeletedTicketLogs for a set of ticket IDs.
Uses POST /DeletedTicketLogs/query.
Returns list items including ticketID, ticketNumber, deletedByResourceID, deletedDateTime.
"""
ids: List[int] = []
for x in ticket_ids or []:
try:
v = int(x)
except Exception:
continue
if v > 0:
ids.append(v)
if not ids:
return []
# Field name differs across docs/tenants (ticketID vs ticketId).
# Autotask query field matching is case-insensitive in most tenants; we use the common ticketID.
payload = {
"filter": [
{"op": "in", "field": "ticketID", "value": ids},
]
}
data = self._request("POST", "DeletedTicketLogs/query", json_body=payload)
return self._as_items_list(data)
def query_tickets_by_ids(
self,
ticket_ids: List[int],

View File

@ -109,13 +109,14 @@ def _resolve_internal_ticket_for_job(
job: Job | None,
run_ids: list[int],
now: datetime,
origin: str = "psa",
) -> None:
"""Resolve the ticket (and its job scope) as PSA-driven, best-effort."""
if ticket.resolved_at is None:
ticket.resolved_at = now
if getattr(ticket, "resolved_origin", None) is None:
ticket.resolved_origin = "psa"
ticket.resolved_origin = origin
# Resolve all still-open scopes.
try:
@ -191,6 +192,89 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None:
now = datetime.utcnow()
ticket_ids = sorted(ticket_to_runs.keys())
# Deleted tickets: check DeletedTicketLogs first (authoritative).
deleted_map: dict[int, dict] = {}
try:
deleted_items = client.query_deleted_ticket_logs_by_ticket_ids(ticket_ids)
except Exception:
deleted_items = []
for it in deleted_items or []:
if not isinstance(it, dict):
continue
raw_tid = it.get("ticketID") if "ticketID" in it else it.get("ticketId")
try:
tid_int = int(raw_tid) if raw_tid is not None else 0
except Exception:
tid_int = 0
if tid_int <= 0:
continue
deleted_map[tid_int] = it
# Persist deleted audit fields on runs and resolve internal ticket as PSA-deleted.
for tid, item in deleted_map.items():
runs_for_ticket = ticket_to_runs.get(tid) or []
if not runs_for_ticket:
continue
deleted_by = item.get("deletedByResourceID") if "deletedByResourceID" in item else item.get("deletedByResourceId")
deleted_dt_raw = item.get("deletedDateTime") or item.get("deletedDatetime") or item.get("deletedAt")
deleted_dt = None
if deleted_dt_raw:
try:
s = str(deleted_dt_raw).replace("Z", "+00:00")
deleted_dt = datetime.fromisoformat(s)
if deleted_dt.tzinfo is not None:
deleted_dt = deleted_dt.astimezone(timezone.utc).replace(tzinfo=None)
except Exception:
deleted_dt = None
try:
deleted_by_int = int(deleted_by) if deleted_by is not None else None
except Exception:
deleted_by_int = None
# Backfill ticket number (if present in log)
ticket_number = item.get("ticketNumber") or item.get("ticket_number")
for rr in runs_for_ticket:
if deleted_dt and getattr(rr, "autotask_ticket_deleted_at", None) is None:
rr.autotask_ticket_deleted_at = deleted_dt
if deleted_by_int and getattr(rr, "autotask_ticket_deleted_by_resource_id", None) is None:
rr.autotask_ticket_deleted_by_resource_id = deleted_by_int
if ticket_number and not (getattr(rr, "autotask_ticket_number", None) or "").strip():
rr.autotask_ticket_number = str(ticket_number).strip()
db.session.add(rr)
# Resolve internal ticket with origin psa_deleted (best-effort)
tn = ""
if ticket_number:
tn = str(ticket_number).strip()
if not tn:
for rr in runs_for_ticket:
if (getattr(rr, "autotask_ticket_number", None) or "").strip():
tn = rr.autotask_ticket_number.strip()
break
job = Job.query.get(runs_for_ticket[0].job_id) if runs_for_ticket else None
active_from_dt = None
try:
dts = [getattr(x, "run_at", None) for x in runs_for_ticket if getattr(x, "run_at", None)]
active_from_dt = min(dts) if dts else None
except Exception:
active_from_dt = None
internal_ticket = _ensure_internal_ticket_for_autotask(
ticket_number=tn,
job=job,
run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)],
now=now,
active_from_dt=active_from_dt,
)
if internal_ticket is not None:
_resolve_internal_ticket_for_job(
ticket=internal_ticket,
job=job,
run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)],
now=deleted_dt or now,
origin="psa_deleted",
)
# Optimization: query non-terminal tickets first; fallback to GET by id for missing.
try:
active_items = client.query_tickets_by_ids(ticket_ids, exclude_status_ids=sorted(AUTOTASK_TERMINAL_STATUS_IDS))
@ -206,7 +290,7 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None:
if iid > 0:
active_map[iid] = it
missing_ids = [tid for tid in ticket_ids if tid not in active_map]
missing_ids = [tid for tid in ticket_ids if tid not in active_map and tid not in deleted_map]
# Process active tickets: backfill ticket numbers + ensure internal ticket link.
try:
@ -1178,6 +1262,9 @@ def run_checks_details():
"autotask_ticket_is_resolved": bool(at_resolved),
"autotask_ticket_resolved_origin": at_resolved_origin,
"autotask_ticket_resolved_at": at_resolved_at,
"autotask_ticket_is_deleted": bool(getattr(run, "autotask_ticket_deleted_at", None)),
"autotask_ticket_deleted_at": _format_datetime(getattr(run, "autotask_ticket_deleted_at", None)) if getattr(run, "autotask_ticket_deleted_at", None) else "",
"autotask_ticket_deleted_by_resource_id": getattr(run, "autotask_ticket_deleted_by_resource_id", None),
}
)

View File

@ -924,6 +924,7 @@ def run_migrations() -> None:
migrate_job_runs_review_tracking()
migrate_job_runs_override_metadata()
migrate_job_runs_autotask_ticket_fields()
migrate_job_runs_autotask_ticket_deleted_fields()
migrate_jobs_archiving()
migrate_news_tables()
migrate_reporting_tables()
@ -993,6 +994,46 @@ def migrate_job_runs_autotask_ticket_fields() -> None:
print("[migrations] migrate_job_runs_autotask_ticket_fields completed.")
def migrate_job_runs_autotask_ticket_deleted_fields() -> None:
"""Add Autotask deleted ticket audit fields to job_runs if missing.
Columns:
- job_runs.autotask_ticket_deleted_at (TIMESTAMP NULL)
- job_runs.autotask_ticket_deleted_by_resource_id (INTEGER NULL)
"""
table = "job_runs"
try:
engine = db.get_engine()
except Exception as exc:
print(f"[migrations] Could not get engine for job_runs Autotask ticket deleted fields migration: {exc}")
return
try:
with engine.begin() as conn:
cols = _get_table_columns(conn, table)
if not cols:
print("[migrations] job_runs table not found; skipping migrate_job_runs_autotask_ticket_deleted_fields.")
return
if "autotask_ticket_deleted_at" not in cols:
print("[migrations] Adding job_runs.autotask_ticket_deleted_at column...")
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_deleted_at TIMESTAMP'))
if "autotask_ticket_deleted_by_resource_id" not in cols:
print("[migrations] Adding job_runs.autotask_ticket_deleted_by_resource_id column...")
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_deleted_by_resource_id INTEGER'))
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_deleted_by_resource_id ON "job_runs" (autotask_ticket_deleted_by_resource_id)'))
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_deleted_at ON "job_runs" (autotask_ticket_deleted_at)'))
except Exception as exc:
print(f"[migrations] migrate_job_runs_autotask_ticket_deleted_fields failed (continuing): {exc}")
return
print("[migrations] migrate_job_runs_autotask_ticket_deleted_fields completed.")
def migrate_jobs_archiving() -> None:
"""Add archiving columns to jobs if missing.

View File

@ -281,6 +281,9 @@ class JobRun(db.Model):
autotask_ticket_number = db.Column(db.String(64), nullable=True)
autotask_ticket_created_at = db.Column(db.DateTime, nullable=True)
autotask_ticket_created_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
autotask_ticket_deleted_at = db.Column(db.DateTime, nullable=True)
autotask_ticket_deleted_by_resource_id = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

View File

@ -884,10 +884,18 @@ table.addEventListener('change', function (e) {
var num = (run && run.autotask_ticket_number) ? String(run.autotask_ticket_number) : '';
var isResolved = !!(run && run.autotask_ticket_is_resolved);
var origin = (run && run.autotask_ticket_resolved_origin) ? String(run.autotask_ticket_resolved_origin) : '';
var isDeleted = !!(run && run.autotask_ticket_is_deleted);
var deletedAt = (run && run.autotask_ticket_deleted_at) ? String(run.autotask_ticket_deleted_at) : '';
var deletedBy = (run && run.autotask_ticket_deleted_by_resource_id) ? String(run.autotask_ticket_deleted_by_resource_id) : '';
if (num) {
var extra = '';
if (isResolved && origin === 'psa') {
if (isDeleted) {
var meta = '';
if (deletedAt) meta += '<div class="text-muted">Deleted at: ' + escapeHtml(deletedAt) + '</div>';
if (deletedBy) meta += '<div class="text-muted">Deleted by resource ID: ' + escapeHtml(deletedBy) + '</div>';
extra = '<div class="mt-1"><span class="badge bg-danger">Deleted in PSA</span></div>' + meta;
} else if (isResolved && origin === 'psa') {
extra = '<div class="mt-1"><span class="badge bg-secondary">Resolved by PSA</span></div>';
}
atInfo.innerHTML = '<div><strong>Ticket:</strong> ' + escapeHtml(num) + '</div>' + extra;
@ -898,7 +906,7 @@ table.addEventListener('change', function (e) {
}
if (btnAutotask) {
if (run && run.autotask_ticket_id && isResolved) btnAutotask.textContent = 'Create new';
if (run && run.autotask_ticket_id && (isResolved || isDeleted)) btnAutotask.textContent = 'Create new';
else btnAutotask.textContent = 'Create';
}
}
@ -908,7 +916,8 @@ table.addEventListener('change', function (e) {
if (!btnAutotask) return;
var hasTicket = !!(run && run.autotask_ticket_id);
var isResolved = !!(run && run.autotask_ticket_is_resolved);
btnAutotask.textContent = (hasTicket && isResolved) ? 'Create new' : 'Create';
var isDeleted = !!(run && run.autotask_ticket_is_deleted);
btnAutotask.textContent = (hasTicket && (isResolved || isDeleted)) ? 'Create new' : 'Create';
};