Auto-commit local changes before build (2026-01-19 15:10:00)

This commit is contained in:
Ivo Oskamp 2026-01-19 15:10:00 +01:00
parent 0c5dee307f
commit 0cabd2e0fc
7 changed files with 184 additions and 7 deletions

View File

@ -1 +1 @@
v20260119-12-autotask-ticket-state-sync v20260119-13-autotask-psa-resolved-recreate

View File

@ -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() 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: if open_scope is None and ticket.resolved_at is None:
ticket.resolved_at = now ticket.resolved_at = now
if getattr(ticket, "resolved_origin", None) is None:
ticket.resolved_origin = "backupchecks"
db.session.commit() db.session.commit()
except Exception as exc: 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 # Global resolve (from central ticket list): resolve ticket and all scopes
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:
ticket.resolved_origin = "backupchecks"
try: try:
# Resolve any still-open scopes # Resolve any still-open scopes

View File

@ -111,6 +111,8 @@ def _resolve_internal_ticket_for_job(
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:
ticket.resolved_origin = "psa"
# Resolve all still-open scopes. # Resolve all still-open scopes.
try: 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() 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 = [] runs_payload = []
for run in runs: for run in runs:
msg = MailMessage.query.get(run.mail_message_id) if run.mail_message_id else None 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: except Exception:
pass 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 "-" status_display = run.status or "-"
try: try:
status_display, _, _, _ov_id, _ov_reason = _apply_overrides_to_run(job, run) status_display, _, _, _ov_id, _ov_reason = _apply_overrides_to_run(job, run)
@ -1127,6 +1157,9 @@ def run_checks_details():
"objects": objects_payload, "objects": objects_payload,
"autotask_ticket_id": getattr(run, "autotask_ticket_id", None), "autotask_ticket_id": getattr(run, "autotask_ticket_id", None),
"autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "", "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: if not run:
return jsonify({"status": "error", "message": "Run not found."}), 404 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): 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", "status": "ok",
"ticket_id": int(run.autotask_ticket_id), "ticket_id": int(run.autotask_ticket_id),

View File

@ -894,6 +894,7 @@ def run_migrations() -> None:
migrate_feedback_tables() migrate_feedback_tables()
migrate_feedback_replies_table() migrate_feedback_replies_table()
migrate_tickets_active_from_date() migrate_tickets_active_from_date()
migrate_tickets_resolved_origin()
migrate_remarks_active_from_date() migrate_remarks_active_from_date()
migrate_overrides_match_columns() migrate_overrides_match_columns()
migrate_job_runs_review_tracking() 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: def migrate_mail_messages_overall_message() -> None:
"""Add overall_message column to mail_messages if missing.""" """Add overall_message column to mail_messages if missing."""
table = "mail_messages" table = "mail_messages"

View File

@ -421,6 +421,8 @@ class Ticket(db.Model):
# Audit timestamp: when the ticket was created (UTC, naive) # Audit timestamp: when the ticket was created (UTC, naive)
start_date = db.Column(db.DateTime, nullable=False) start_date = db.Column(db.DateTime, nullable=False)
resolved_at = db.Column(db.DateTime) 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) 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) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@ -216,12 +216,21 @@
<div class="row g-2 align-items-start"> <div class="row g-2 align-items-start">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="border rounded p-2"> <div class="border rounded p-2">
{% if autotask_enabled %}
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
<div class="fw-semibold">Autotask ticket</div> <div class="fw-semibold">Autotask ticket</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button> <button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button>
</div> </div>
<div class="mt-2 small" id="rcm_autotask_info"></div> <div class="mt-2 small" id="rcm_autotask_info"></div>
<div class="mt-2 small text-muted" id="rcm_autotask_status"></div> <div class="mt-2 small text-muted" id="rcm_autotask_status"></div>
{% else %}
<div class="fw-semibold">New ticket</div>
<div class="d-flex gap-2 mt-1">
<input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button>
</div>
<div class="mt-1 small text-muted" id="rcm_ticket_status"></div>
{% endif %}
</div> </div>
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
@ -297,6 +306,8 @@
var currentRunId = null; var currentRunId = null;
var currentPayload = null; var currentPayload = null;
var autotaskEnabled = {{ 'true' if autotask_enabled else 'false' }};
var btnMarkAllReviewed = document.getElementById('rcm_mark_all_reviewed'); var btnMarkAllReviewed = document.getElementById('rcm_mark_all_reviewed');
var btnMarkSuccessOverride = document.getElementById('rcm_mark_success_override'); 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 atInfo = document.getElementById('rcm_autotask_info');
var atStatus = document.getElementById('rcm_autotask_status'); 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 btnRemark = document.getElementById('rcm_remark_save');
var rBody = document.getElementById('rcm_remark_body'); var rBody = document.getElementById('rcm_remark_body');
var rStatus = document.getElementById('rcm_remark_status'); var rStatus = document.getElementById('rcm_remark_status');
function clearStatus() { function clearStatus() {
if (atStatus) atStatus.textContent = ''; if (atStatus) atStatus.textContent = '';
if (tStatus) tStatus.textContent = '';
if (rStatus) rStatus.textContent = ''; if (rStatus) rStatus.textContent = '';
} }
function setDisabled(disabled) { function setDisabled(disabled) {
if (btnAutotask) btnAutotask.disabled = disabled; if (btnAutotask) btnAutotask.disabled = disabled;
if (btnTicket) btnTicket.disabled = disabled;
if (tCode) tCode.disabled = disabled;
if (btnRemark) btnRemark.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled;
if (rBody) rBody.disabled = disabled; if (rBody) rBody.disabled = disabled;
} }
@ -864,16 +882,72 @@ table.addEventListener('change', function (e) {
function renderAutotaskInfo(run) { function renderAutotaskInfo(run) {
if (!atInfo) return; if (!atInfo) return;
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 origin = (run && run.autotask_ticket_resolved_origin) ? String(run.autotask_ticket_resolved_origin) : '';
if (num) { if (num) {
atInfo.innerHTML = '<div><strong>Ticket:</strong> ' + escapeHtml(num) + '</div>'; var extra = '';
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;
} else if (run && run.autotask_ticket_id) { } else if (run && run.autotask_ticket_id) {
atInfo.innerHTML = '<div><strong>Ticket:</strong> created</div>'; atInfo.innerHTML = '<div><strong>Ticket:</strong> created</div>';
} else { } else {
atInfo.innerHTML = '<div class="text-muted">No Autotask ticket created for this run.</div>'; atInfo.innerHTML = '<div class="text-muted">No Autotask ticket created for this run.</div>';
} }
if (btnAutotask) {
if (run && run.autotask_ticket_id && isResolved) btnAutotask.textContent = 'Create new';
else btnAutotask.textContent = 'Create';
}
} }
window.__rcmRenderAutotaskInfo = renderAutotaskInfo; 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) { if (btnAutotask) {
btnAutotask.addEventListener('click', function () { btnAutotask.addEventListener('click', function () {
if (!currentRunId) { alert('Select a run first.'); return; } 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++) { for (var i = 0; i < runs.length; i++) {
if (String(runs[i].id) === String(keepRunId)) { idx = i; break; } 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.'); else alert(e.message || 'Failed.');
}) })
.finally(function () { .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; currentRunId = run.id || null;
if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus(); if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus();
if (window.__rcmRenderAutotaskInfo) window.__rcmRenderAutotaskInfo(run); 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) { if (btnMarkSuccessOverride) {
var _rs = (run.status || '').toString().toLowerCase(); var _rs = (run.status || '').toString().toLowerCase();
var _canOverride = !!currentRunId && !run.missed && (_rs.indexOf('override') === -1) && (_rs.indexOf('success') === -1); var _canOverride = !!currentRunId && !run.missed && (_rs.indexOf('override') === -1) && (_rs.indexOf('success') === -1);

View File

@ -290,6 +290,16 @@ Changes:
- Ensured multi-run consistency: one Autotask ticket correctly resolves all associated active job runs. - 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. - 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 ## v0.1.21