diff --git a/.last-branch b/.last-branch index ded7de3..cfed06c 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260119-12-autotask-ticket-state-sync +v20260119-13-autotask-psa-resolved-recreate diff --git a/containers/backupchecks/src/backend/app/main/routes_api.py b/containers/backupchecks/src/backend/app/main/routes_api.py index 0da7b4f..395968a 100644 --- a/containers/backupchecks/src/backend/app/main/routes_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_api.py @@ -347,6 +347,8 @@ def api_ticket_resolve(ticket_id: int): open_scope = TicketScope.query.filter_by(ticket_id=ticket.id, resolved_at=None).first() if open_scope is None and ticket.resolved_at is None: ticket.resolved_at = now + if getattr(ticket, "resolved_origin", None) is None: + ticket.resolved_origin = "backupchecks" db.session.commit() except Exception as exc: @@ -358,6 +360,8 @@ def api_ticket_resolve(ticket_id: int): # Global resolve (from central ticket list): resolve ticket and all scopes if ticket.resolved_at is None: ticket.resolved_at = now + if getattr(ticket, "resolved_origin", None) is None: + ticket.resolved_origin = "backupchecks" try: # Resolve any still-open scopes 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 bb07517..9e67216 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -111,6 +111,8 @@ def _resolve_internal_ticket_for_job( if ticket.resolved_at is None: ticket.resolved_at = now + if getattr(ticket, "resolved_origin", None) is None: + ticket.resolved_origin = "psa" # Resolve all still-open scopes. try: @@ -999,6 +1001,20 @@ def run_checks_details(): runs = q.order_by(func.coalesce(JobRun.run_at, JobRun.created_at).desc(), JobRun.id.desc()).limit(400).all() + # Prefetch internal ticket resolution info for Autotask-linked runs (Phase 2 UI). + autotask_codes = set() + for _r in runs: + code = (getattr(_r, "autotask_ticket_number", None) or "").strip() + if code: + autotask_codes.add(code) + ticket_by_code = {} + if autotask_codes: + try: + for _t in Ticket.query.filter(Ticket.ticket_code.in_(list(autotask_codes))).all(): + ticket_by_code[_t.ticket_code] = _t + except Exception: + ticket_by_code = {} + runs_payload = [] for run in runs: msg = MailMessage.query.get(run.mail_message_id) if run.mail_message_id else None @@ -1104,6 +1120,20 @@ def run_checks_details(): except Exception: pass + # Autotask ticket resolution info (derived from internal Ticket) + at_resolved = False + at_resolved_origin = "" + at_resolved_at = "" + try: + _code = (getattr(run, "autotask_ticket_number", None) or "").strip() + if _code and _code in ticket_by_code: + _t = ticket_by_code[_code] + at_resolved = getattr(_t, "resolved_at", None) is not None + at_resolved_origin = (getattr(_t, "resolved_origin", None) or "") + at_resolved_at = _format_datetime(getattr(_t, "resolved_at", None)) if getattr(_t, "resolved_at", None) else "" + except Exception: + pass + status_display = run.status or "-" try: status_display, _, _, _ov_id, _ov_reason = _apply_overrides_to_run(job, run) @@ -1127,6 +1157,9 @@ def run_checks_details(): "objects": objects_payload, "autotask_ticket_id": getattr(run, "autotask_ticket_id", None), "autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "", + "autotask_ticket_is_resolved": bool(at_resolved), + "autotask_ticket_resolved_origin": at_resolved_origin, + "autotask_ticket_resolved_at": at_resolved_at, } ) @@ -1165,9 +1198,27 @@ def api_run_checks_create_autotask_ticket(): if not run: return jsonify({"status": "error", "message": "Run not found."}), 404 - # Idempotent: if already created, return existing linkage. + # If a ticket is already linked we normally prevent duplicate creation. + # Exception: if the linked ticket is resolved (e.g. resolved by PSA), allow creating a new ticket. if getattr(run, "autotask_ticket_id", None): - return jsonify( + already_resolved = False + try: + code = (getattr(run, "autotask_ticket_number", None) or "").strip() + if code: + t = Ticket.query.filter_by(ticket_code=code).first() + already_resolved = bool(getattr(t, "resolved_at", None)) if t else False + except Exception: + already_resolved = False + if not already_resolved: + return jsonify( + { + "status": "ok", + "ticket_id": int(run.autotask_ticket_id), + "ticket_number": getattr(run, "autotask_ticket_number", None) or "", + "already_exists": True, + } + ) + # resolved -> continue, create a new Autotask ticket and overwrite current linkage. { "status": "ok", "ticket_id": int(run.autotask_ticket_id), diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index b1d1405..69fa355 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -894,6 +894,7 @@ def run_migrations() -> None: migrate_feedback_tables() migrate_feedback_replies_table() migrate_tickets_active_from_date() + migrate_tickets_resolved_origin() migrate_remarks_active_from_date() migrate_overrides_match_columns() migrate_job_runs_review_tracking() @@ -1253,6 +1254,33 @@ def migrate_tickets_active_from_date() -> None: + +def migrate_tickets_resolved_origin() -> None: + """Add tickets.resolved_origin column if missing. + + Used to show whether a ticket was resolved by PSA polling or manually inside Backupchecks. + """ + + table = "tickets" + try: + engine = db.get_engine() + except Exception as exc: + print(f"[migrations] Could not get engine for tickets resolved_origin migration: {exc}") + return + + try: + with engine.connect() as conn: + cols = _get_table_columns(conn, table) + if not cols: + return + if "resolved_origin" not in cols: + print("[migrations] Adding tickets.resolved_origin column...") + conn.execute(text('ALTER TABLE "tickets" ADD COLUMN resolved_origin VARCHAR(32)')) + except Exception as exc: + print(f"[migrations] tickets resolved_origin migration failed (continuing): {exc}") + + print("[migrations] migrate_tickets_resolved_origin completed.") + def migrate_mail_messages_overall_message() -> None: """Add overall_message column to mail_messages if missing.""" table = "mail_messages" diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index 4ecba7d..b799cc4 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -421,6 +421,8 @@ class Ticket(db.Model): # Audit timestamp: when the ticket was created (UTC, naive) start_date = db.Column(db.DateTime, nullable=False) resolved_at = db.Column(db.DateTime) + # Resolution origin for audit/UI: psa | backupchecks + resolved_origin = db.Column(db.String(32)) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=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 dcf56ca..b06bd5b 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -216,12 +216,21 @@
+ {% if autotask_enabled %}
Autotask ticket
+ {% else %} +
New ticket
+
+ + +
+
+ {% endif %}
@@ -297,6 +306,8 @@ var currentRunId = null; var currentPayload = null; + var autotaskEnabled = {{ 'true' if autotask_enabled else 'false' }}; + var btnMarkAllReviewed = document.getElementById('rcm_mark_all_reviewed'); var btnMarkSuccessOverride = document.getElementById('rcm_mark_success_override'); @@ -843,17 +854,24 @@ table.addEventListener('change', function (e) { var atInfo = document.getElementById('rcm_autotask_info'); var atStatus = document.getElementById('rcm_autotask_status'); + var btnTicket = document.getElementById('rcm_ticket_save'); + var tCode = document.getElementById('rcm_ticket_code'); + var tStatus = document.getElementById('rcm_ticket_status'); + var btnRemark = document.getElementById('rcm_remark_save'); var rBody = document.getElementById('rcm_remark_body'); var rStatus = document.getElementById('rcm_remark_status'); function clearStatus() { if (atStatus) atStatus.textContent = ''; + if (tStatus) tStatus.textContent = ''; if (rStatus) rStatus.textContent = ''; } function setDisabled(disabled) { if (btnAutotask) btnAutotask.disabled = disabled; + if (btnTicket) btnTicket.disabled = disabled; + if (tCode) tCode.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled; if (rBody) rBody.disabled = disabled; } @@ -864,16 +882,72 @@ table.addEventListener('change', function (e) { function renderAutotaskInfo(run) { 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) : ''; + if (num) { - atInfo.innerHTML = '
Ticket: ' + escapeHtml(num) + '
'; + var extra = ''; + if (isResolved && origin === 'psa') { + extra = '
Resolved by PSA
'; + } + atInfo.innerHTML = '
Ticket: ' + escapeHtml(num) + '
' + extra; } else if (run && run.autotask_ticket_id) { atInfo.innerHTML = '
Ticket: created
'; } else { atInfo.innerHTML = '
No Autotask ticket created for this run.
'; } + + if (btnAutotask) { + if (run && run.autotask_ticket_id && isResolved) btnAutotask.textContent = 'Create new'; + else btnAutotask.textContent = 'Create'; + } } window.__rcmRenderAutotaskInfo = renderAutotaskInfo; + window.__rcmSetAutotaskCreateLabel = function (run) { + 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'; + }; + + + function isValidTicketCode(code) { + return /^T\d{8}\.\d{4}$/.test(code); + } + + if (btnTicket) { + btnTicket.addEventListener('click', function () { + if (!currentRunId) { alert('Select a run first.'); return; } + clearStatus(); + var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; + if (!ticket_code) { + if (tStatus) tStatus.textContent = 'Ticket number is required.'; + else alert('Ticket number is required.'); + return; + } + if (!isValidTicketCode(ticket_code)) { + if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.'; + else alert('Invalid ticket number format. Expected TYYYYMMDD.####.'); + return; + } + if (tStatus) tStatus.textContent = 'Saving...'; + apiJson('/api/tickets', { + method: 'POST', + body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code}) + }) + .then(function () { + if (tCode) tCode.value = ''; + if (tStatus) tStatus.textContent = ''; + loadAlerts(currentRunId); + }) + .catch(function (e) { + if (tStatus) tStatus.textContent = e.message || 'Failed.'; + else alert(e.message || 'Failed.'); + }); + }); + } + if (btnAutotask) { btnAutotask.addEventListener('click', function () { if (!currentRunId) { alert('Select a run first.'); return; } @@ -901,7 +975,7 @@ table.addEventListener('change', function (e) { for (var i = 0; i < runs.length; i++) { if (String(runs[i].id) === String(keepRunId)) { idx = i; break; } } - renderRun(payload, idx); + renderModal(payload, idx); }); } }) @@ -910,7 +984,7 @@ table.addEventListener('change', function (e) { else alert(e.message || 'Failed.'); }) .finally(function () { - // State will be recalculated by renderRun. + // State will be recalculated by renderModal/renderRun. }); }); } @@ -977,7 +1051,15 @@ table.addEventListener('change', function (e) { currentRunId = run.id || null; if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus(); if (window.__rcmRenderAutotaskInfo) window.__rcmRenderAutotaskInfo(run); - if (window.__rcmSetCreateDisabled) window.__rcmSetCreateDisabled(!currentRunId || !!run.autotask_ticket_id); + if (window.__rcmSetAutotaskCreateLabel) window.__rcmSetAutotaskCreateLabel(run); + if (window.__rcmSetCreateDisabled) { + if (autotaskEnabled) { + var canCreateAt = !!currentRunId && (!run.autotask_ticket_id || !!run.autotask_ticket_is_resolved); + window.__rcmSetCreateDisabled(!canCreateAt); + } else { + window.__rcmSetCreateDisabled(!currentRunId); + } + } if (btnMarkSuccessOverride) { var _rs = (run.status || '').toString().toLowerCase(); var _canOverride = !!currentRunId && !run.missed && (_rs.indexOf('override') === -1) && (_rs.indexOf('success') === -1); diff --git a/docs/changelog.md b/docs/changelog.md index 8c824a8..fd4a731 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -290,6 +290,16 @@ Changes: - Ensured multi-run consistency: one Autotask ticket correctly resolves all associated active job runs. - Preserved internal Ticket and TicketJobRun integrity to maintain legacy Tickets, Remarks, and Job Details behaviour. +## v20260119-04-autotask-psa-resolved-ui-recreate-ticket + +### Changes: +- Added explicit UI indication when an Autotask ticket is resolved by PSA ("Resolved by PSA (Autotask)"). +- Differentiated resolution origin between PSA-driven resolution and Backupchecks-driven resolution. +- Re-enabled ticket creation when an existing Autotask ticket was resolved by PSA, allowing operators to create a new ticket if the previous one was closed incorrectly. +- Updated Autotask ticket panel to reflect resolved state without blocking further actions. +- Extended backend validation to allow ticket re-creation after PSA-resolved tickets while preserving historical ticket links. +- Ensured legacy Tickets, Remarks, and Job Details behaviour remains intact. + *** ## v0.1.21