Auto-commit local changes before build (2026-01-20 08:49:15)
This commit is contained in:
parent
63526be592
commit
5131d24751
@ -1 +1 @@
|
|||||||
v20260119-18-fix-legacy-ticketnumber-sync
|
v20260120-01-autotask-deleted-ticket-detection
|
||||||
|
|||||||
@ -483,6 +483,37 @@ class AutotaskClient:
|
|||||||
raise AutotaskError("Autotask did not return a ticket object.")
|
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(
|
def query_tickets_by_ids(
|
||||||
self,
|
self,
|
||||||
ticket_ids: List[int],
|
ticket_ids: List[int],
|
||||||
|
|||||||
@ -109,13 +109,14 @@ def _resolve_internal_ticket_for_job(
|
|||||||
job: Job | None,
|
job: Job | None,
|
||||||
run_ids: list[int],
|
run_ids: list[int],
|
||||||
now: datetime,
|
now: datetime,
|
||||||
|
origin: str = "psa",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Resolve the ticket (and its job scope) as PSA-driven, best-effort."""
|
"""Resolve the ticket (and its job scope) as PSA-driven, best-effort."""
|
||||||
|
|
||||||
if ticket.resolved_at is None:
|
if ticket.resolved_at is None:
|
||||||
ticket.resolved_at = now
|
ticket.resolved_at = now
|
||||||
if getattr(ticket, "resolved_origin", None) is None:
|
if getattr(ticket, "resolved_origin", None) is None:
|
||||||
ticket.resolved_origin = "psa"
|
ticket.resolved_origin = origin
|
||||||
|
|
||||||
# Resolve all still-open scopes.
|
# Resolve all still-open scopes.
|
||||||
try:
|
try:
|
||||||
@ -191,6 +192,89 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None:
|
|||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
ticket_ids = sorted(ticket_to_runs.keys())
|
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.
|
# Optimization: query non-terminal tickets first; fallback to GET by id for missing.
|
||||||
try:
|
try:
|
||||||
active_items = client.query_tickets_by_ids(ticket_ids, exclude_status_ids=sorted(AUTOTASK_TERMINAL_STATUS_IDS))
|
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:
|
if iid > 0:
|
||||||
active_map[iid] = it
|
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.
|
# Process active tickets: backfill ticket numbers + ensure internal ticket link.
|
||||||
try:
|
try:
|
||||||
@ -1178,6 +1262,9 @@ def run_checks_details():
|
|||||||
"autotask_ticket_is_resolved": bool(at_resolved),
|
"autotask_ticket_is_resolved": bool(at_resolved),
|
||||||
"autotask_ticket_resolved_origin": at_resolved_origin,
|
"autotask_ticket_resolved_origin": at_resolved_origin,
|
||||||
"autotask_ticket_resolved_at": at_resolved_at,
|
"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),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -924,6 +924,7 @@ def run_migrations() -> None:
|
|||||||
migrate_job_runs_review_tracking()
|
migrate_job_runs_review_tracking()
|
||||||
migrate_job_runs_override_metadata()
|
migrate_job_runs_override_metadata()
|
||||||
migrate_job_runs_autotask_ticket_fields()
|
migrate_job_runs_autotask_ticket_fields()
|
||||||
|
migrate_job_runs_autotask_ticket_deleted_fields()
|
||||||
migrate_jobs_archiving()
|
migrate_jobs_archiving()
|
||||||
migrate_news_tables()
|
migrate_news_tables()
|
||||||
migrate_reporting_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.")
|
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:
|
def migrate_jobs_archiving() -> None:
|
||||||
"""Add archiving columns to jobs if missing.
|
"""Add archiving columns to jobs if missing.
|
||||||
|
|
||||||
|
|||||||
@ -281,6 +281,9 @@ class JobRun(db.Model):
|
|||||||
autotask_ticket_number = db.Column(db.String(64), nullable=True)
|
autotask_ticket_number = db.Column(db.String(64), nullable=True)
|
||||||
autotask_ticket_created_at = db.Column(db.DateTime, 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_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)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|||||||
@ -884,10 +884,18 @@ table.addEventListener('change', function (e) {
|
|||||||
var num = (run && run.autotask_ticket_number) ? String(run.autotask_ticket_number) : '';
|
var num = (run && run.autotask_ticket_number) ? String(run.autotask_ticket_number) : '';
|
||||||
var isResolved = !!(run && run.autotask_ticket_is_resolved);
|
var isResolved = !!(run && run.autotask_ticket_is_resolved);
|
||||||
var origin = (run && run.autotask_ticket_resolved_origin) ? String(run.autotask_ticket_resolved_origin) : '';
|
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) {
|
if (num) {
|
||||||
var extra = '';
|
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>';
|
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;
|
atInfo.innerHTML = '<div><strong>Ticket:</strong> ' + escapeHtml(num) + '</div>' + extra;
|
||||||
@ -898,7 +906,7 @@ table.addEventListener('change', function (e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (btnAutotask) {
|
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';
|
else btnAutotask.textContent = 'Create';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -908,7 +916,8 @@ table.addEventListener('change', function (e) {
|
|||||||
if (!btnAutotask) return;
|
if (!btnAutotask) return;
|
||||||
var hasTicket = !!(run && run.autotask_ticket_id);
|
var hasTicket = !!(run && run.autotask_ticket_id);
|
||||||
var isResolved = !!(run && run.autotask_ticket_is_resolved);
|
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';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user