diff --git a/.last-branch b/.last-branch index a80b69a..a060015 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260120-02-autotask-deleted-ticket-detection +v20260120-03-autotask-deletedby-name-runlink diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index c3d21b4..e3848b2 100644 --- a/containers/backupchecks/src/backend/app/integrations/autotask/client.py +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -483,6 +483,40 @@ class AutotaskClient: raise AutotaskError("Autotask did not return a ticket object.") + def get_resource(self, resource_id: int) -> Dict[str, Any]: + """Retrieve a Resource by Autotask Resource ID. + + Uses GET /Resources/{id}. + + Returns the resource object (fields depend on permissions). + """ + + try: + rid = int(resource_id) + except Exception: + raise AutotaskError("Invalid resource id.") + + if rid <= 0: + raise AutotaskError("Invalid resource id.") + + data = self._request("GET", f"Resources/{rid}") + if isinstance(data, dict): + if "item" in data and isinstance(data.get("item"), dict): + return data["item"] + if "items" in data and isinstance(data.get("items"), list) and data.get("items"): + first = data.get("items")[0] + if isinstance(first, dict): + return first + if "id" in data or "firstName" in data or "lastName" in data: + return data + + items = self._as_items_list(data) + if items: + return items[0] + + raise AutotaskError("Autotask did not return a resource 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. diff --git a/containers/backupchecks/src/backend/app/mail_importer.py b/containers/backupchecks/src/backend/app/mail_importer.py index d2479c7..cbe2bb0 100644 --- a/containers/backupchecks/src/backend/app/mail_importer.py +++ b/containers/backupchecks/src/backend/app/mail_importer.py @@ -16,6 +16,7 @@ from .parsers import parse_mail_message from .parsers.veeam import extract_vspc_active_alarms_companies from .email_utils import normalize_from_address, extract_best_html_from_eml, is_effectively_blank_html from .job_matching import find_matching_job +from .ticketing_utils import link_open_internal_tickets_to_run GRAPH_TOKEN_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" @@ -248,6 +249,12 @@ def _store_messages(settings: SystemSettings, messages): db.session.add(mail) db.session.flush() + # Link any open internal tickets to this new run (legacy behavior). + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass + # Immediately run parsers so Inbox / Jobs can show parsed metadata + objects. try: parse_mail_message(mail) @@ -334,6 +341,12 @@ def _store_messages(settings: SystemSettings, messages): db.session.add(run) db.session.flush() + # Link any open internal tickets to this new run (legacy behavior). + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass + auto_approved_runs.append((job.customer_id, job.id, run.id, mail.id)) created_any = True diff --git a/containers/backupchecks/src/backend/app/main/routes_inbox.py b/containers/backupchecks/src/backend/app/main/routes_inbox.py index 5ed206f..9998f84 100644 --- a/containers/backupchecks/src/backend/app/main/routes_inbox.py +++ b/containers/backupchecks/src/backend/app/main/routes_inbox.py @@ -4,6 +4,7 @@ from .routes_shared import _format_datetime, _log_admin_event, _send_mail_messag from ..email_utils import extract_best_html_from_eml, is_effectively_blank_html from ..parsers.veeam import extract_vspc_active_alarms_companies from ..models import MailObject +from ..ticketing_utils import link_open_internal_tickets_to_run import time import re @@ -294,6 +295,11 @@ def inbox_message_approve(message_id: int): if hasattr(run, 'storage_free_percent') and hasattr(msg, 'storage_free_percent'): run.storage_free_percent = msg.storage_free_percent db.session.add(run) + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass # Update mail message to reflect approval msg.job_id = job.id @@ -537,6 +543,21 @@ def inbox_message_approve_vspc_companies(message_id: int): run.remark = getattr(msg, "overall_message", None) db.session.add(run) + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass db.session.flush() created_runs.append(run) @@ -683,6 +704,21 @@ def inbox_message_approve_vspc_companies(message_id: int): if hasattr(run2, "remark"): run2.remark = getattr(other, "overall_message", None) db.session.add(run2) + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run2, job=job) + except Exception: + pass + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run2, job=job) + except Exception: + pass + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run2, job=job) + except Exception: + pass db.session.flush() # Persist objects per company @@ -1049,6 +1085,21 @@ def inbox_reparse_all(): run.storage_free_percent = msg.storage_free_percent db.session.add(run) + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass db.session.flush() auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id)) created_any = True @@ -1109,6 +1160,21 @@ def inbox_reparse_all(): run.storage_free_percent = msg.storage_free_percent db.session.add(run) + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass db.session.flush() # ensure run.id is available auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id)) @@ -1208,6 +1274,21 @@ def inbox_reparse_all(): run.storage_free_percent = msg.storage_free_percent db.session.add(run) + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass + db.session.flush() + try: + link_open_internal_tickets_to_run(run=run, job=job) + except Exception: + pass db.session.flush() auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id)) 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 845e5ef..b0a88cf 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -38,6 +38,7 @@ from ..models import ( TicketScope, User, ) +from ..ticketing_utils import link_open_internal_tickets_to_run AUTOTASK_TERMINAL_STATUS_IDS = {5} @@ -211,6 +212,36 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None: continue deleted_map[tid_int] = it + # Resolve deletedByResourceID to display names (best-effort, cached per request). + resource_name_map: dict[int, tuple[str, str]] = {} + try: + resource_ids = set() + for item in deleted_map.values(): + if not isinstance(item, dict): + continue + raw = item.get("deletedByResourceID") if "deletedByResourceID" in item else item.get("deletedByResourceId") + try: + rid = int(raw) if raw is not None else 0 + except Exception: + rid = 0 + if rid > 0: + resource_ids.add(rid) + + for rid in sorted(resource_ids): + try: + r = client.get_resource(rid) + except Exception: + continue + if not isinstance(r, dict): + continue + fn = (r.get("firstName") or "").strip() + ln = (r.get("lastName") or "").strip() + if fn or ln: + resource_name_map[rid] = (fn, ln) + except Exception: + resource_name_map = {} + + # 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 [] @@ -239,6 +270,15 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> 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 + try: + if deleted_by_int and deleted_by_int in resource_name_map: + fn, ln = resource_name_map.get(deleted_by_int) or ("", "") + if fn and getattr(rr, "autotask_ticket_deleted_by_first_name", None) is None: + rr.autotask_ticket_deleted_by_first_name = fn + if ln and getattr(rr, "autotask_ticket_deleted_by_last_name", None) is None: + rr.autotask_ticket_deleted_by_last_name = ln + except Exception: + pass 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) @@ -669,6 +709,11 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date) mail_message_id=None, ) db.session.add(miss) + db.session.flush() + try: + link_open_internal_tickets_to_run(run=miss, job=job) + except Exception: + pass inserted += 1 d = d + timedelta(days=1) @@ -750,6 +795,11 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date) mail_message_id=None, ) db.session.add(miss) + db.session.flush() + try: + link_open_internal_tickets_to_run(run=miss, job=job) + except Exception: + pass inserted += 1 # Next month @@ -1265,6 +1315,8 @@ def run_checks_details(): "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), + "autotask_ticket_deleted_by_first_name": getattr(run, "autotask_ticket_deleted_by_first_name", None) or "", + "autotask_ticket_deleted_by_last_name": getattr(run, "autotask_ticket_deleted_by_last_name", None) or "", } ) diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index aafa9e9..7dfef46 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -1000,6 +1000,8 @@ def migrate_job_runs_autotask_ticket_deleted_fields() -> None: Columns: - job_runs.autotask_ticket_deleted_at (TIMESTAMP NULL) - job_runs.autotask_ticket_deleted_by_resource_id (INTEGER NULL) + - job_runs.autotask_ticket_deleted_by_first_name (VARCHAR NULL) + - job_runs.autotask_ticket_deleted_by_last_name (VARCHAR NULL) """ table = "job_runs" @@ -1024,8 +1026,19 @@ def migrate_job_runs_autotask_ticket_deleted_fields() -> None: 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')) + if "autotask_ticket_deleted_by_first_name" not in cols: + print("[migrations] Adding job_runs.autotask_ticket_deleted_by_first_name column...") + conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_deleted_by_first_name VARCHAR(128)')) + + if "autotask_ticket_deleted_by_last_name" not in cols: + print("[migrations] Adding job_runs.autotask_ticket_deleted_by_last_name column...") + conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_deleted_by_last_name VARCHAR(128)')) + 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_by_first_name ON "job_runs" (autotask_ticket_deleted_by_first_name)')) + conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_deleted_by_last_name ON "job_runs" (autotask_ticket_deleted_by_last_name)')) + 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}") diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index 703fe6d..188134c 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -283,7 +283,8 @@ class JobRun(db.Model): 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) - + autotask_ticket_deleted_by_first_name = db.Column(db.String(128), nullable=True) + autotask_ticket_deleted_by_last_name = db.Column(db.String(128), nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) diff --git a/containers/backupchecks/src/templates/main/run_checks.html b/containers/backupchecks/src/templates/main/run_checks.html index 977b3d8..c3b12d3 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -883,17 +883,20 @@ table.addEventListener('change', function (e) { if (!atInfo) return; 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 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) : ''; + var deletedFn = (run && run.autotask_ticket_deleted_by_first_name) ? String(run.autotask_ticket_deleted_by_first_name) : ''; + var deletedLn = (run && run.autotask_ticket_deleted_by_last_name) ? String(run.autotask_ticket_deleted_by_last_name) : ''; + var deletedByName = (deletedFn || deletedLn) ? (String(deletedFn || '') + ' ' + String(deletedLn || '')).trim() : ''; if (num) { var extra = ''; if (isDeleted) { var meta = ''; if (deletedAt) meta += '
Deleted at: ' + escapeHtml(deletedAt) + '
'; - if (deletedBy) meta += '
Deleted by resource ID: ' + escapeHtml(deletedBy) + '
'; + if (deletedByName) meta += '
Deleted by: ' + escapeHtml(deletedByName) + '
'; + else if (deletedBy) meta += '
Deleted by resource ID: ' + escapeHtml(deletedBy) + '
'; extra = '
Deleted in PSA
' + meta; } else if (isResolved && origin === 'psa') { extra = '
Resolved by PSA
'; diff --git a/docs/changelog.md b/docs/changelog.md index 60e5a28..f46683e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -367,6 +367,21 @@ Changes: - Ensured resource lookup is executed only when a delete is detected to minimize API usage. - No changes made to Job Details view; data is stored for future reporting use. +## v20260120-03-autotask-deletedby-name-runlink + +### Changes: +- Extended deleted ticket audit handling by resolving DeletedByResourceID to resource details. +- Stored deleted-by audit information on job runs: + - autotask_ticket_deleted_by_first_name + - autotask_ticket_deleted_by_last_name +- Updated Run Checks UI to display: + - “Deleted by: ” + - Fallback to “Deleted by resource ID” when name data is unavailable. +- Ensured deletion date/time continues to be shown in Run Checks. +- Restored legacy ticket behavior by automatically linking new job runs to existing internal tickets (TicketJobRun). +- Ensured Autotask-linked tickets are inherited by new runs when an open ticket already exists for the job. +- No changes made to Job Details view; audit data is stored for future reporting. + *** ## v0.1.21