Auto-commit local changes before build (2026-01-19 15:10:00)
This commit is contained in:
parent
0c5dee307f
commit
0cabd2e0fc
@ -1 +1 @@
|
|||||||
v20260119-12-autotask-ticket-state-sync
|
v20260119-13-autotask-psa-resolved-recreate
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user