Auto-commit local changes before build (2026-01-20 13:10:45)
This commit is contained in:
parent
fc0cf1ef96
commit
92c67805e5
@ -1 +1 @@
|
|||||||
v20260120-07-autotask-psa-resolution-handling
|
v20260120-08-runchecks-link-existing-autotask-ticket
|
||||||
|
|||||||
@ -601,3 +601,59 @@ class AutotaskClient:
|
|||||||
|
|
||||||
data = self._request("POST", "Tickets/query", json_body={"filter": flt})
|
data = self._request("POST", "Tickets/query", json_body={"filter": flt})
|
||||||
return self._as_items_list(data)
|
return self._as_items_list(data)
|
||||||
|
|
||||||
|
def query_tickets_for_company(
|
||||||
|
self,
|
||||||
|
company_id: int,
|
||||||
|
*,
|
||||||
|
search: str = "",
|
||||||
|
exclude_status_ids: Optional[List[int]] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Query Tickets for a specific company, optionally searching by ticket number or title.
|
||||||
|
|
||||||
|
Uses POST /Tickets/query.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Autotask query operators vary by tenant; we use common operators (eq, contains).
|
||||||
|
- If the query fails due to operator support, callers should fall back to get_ticket(id).
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cid = int(company_id)
|
||||||
|
except Exception:
|
||||||
|
cid = 0
|
||||||
|
if cid <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
flt: List[Dict[str, Any]] = [
|
||||||
|
{"op": "eq", "field": "companyID", "value": cid},
|
||||||
|
]
|
||||||
|
|
||||||
|
ex: List[int] = []
|
||||||
|
for x in exclude_status_ids or []:
|
||||||
|
try:
|
||||||
|
v = int(x)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if v > 0:
|
||||||
|
ex.append(v)
|
||||||
|
if ex:
|
||||||
|
flt.append({"op": "notIn", "field": "status", "value": ex})
|
||||||
|
|
||||||
|
q = (search or "").strip()
|
||||||
|
if q:
|
||||||
|
# Ticket numbers in Autotask are typically like T20260119.0004
|
||||||
|
if q.upper().startswith("T") and any(ch.isdigit() for ch in q):
|
||||||
|
flt.append({"op": "eq", "field": "ticketNumber", "value": q.strip()})
|
||||||
|
else:
|
||||||
|
# Broad search on title
|
||||||
|
flt.append({"op": "contains", "field": "title", "value": q})
|
||||||
|
|
||||||
|
data = self._request("POST", "Tickets/query", json_body={"filter": flt})
|
||||||
|
items = self._as_items_list(data)
|
||||||
|
|
||||||
|
# Respect limit if tenant returns more.
|
||||||
|
if limit and isinstance(limit, int) and limit > 0:
|
||||||
|
return items[: int(limit)]
|
||||||
|
return items
|
||||||
|
|||||||
@ -427,8 +427,8 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None:
|
|||||||
else:
|
else:
|
||||||
resolved_at_raw = None
|
resolved_at_raw = None
|
||||||
if resolved_at_raw:
|
if resolved_at_raw:
|
||||||
s = str(resolved_at_raw).replace("Z", "+00:00")
|
s_dt = str(resolved_at_raw).replace("Z", "+00:00")
|
||||||
resolved_at = datetime.fromisoformat(s)
|
resolved_at = datetime.fromisoformat(s_dt)
|
||||||
if resolved_at.tzinfo is not None:
|
if resolved_at.tzinfo is not None:
|
||||||
resolved_at = resolved_at.astimezone(timezone.utc).replace(tzinfo=None)
|
resolved_at = resolved_at.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -446,7 +446,7 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None:
|
|||||||
job=job,
|
job=job,
|
||||||
run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)],
|
run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)],
|
||||||
now=resolved_at or now,
|
now=resolved_at or now,
|
||||||
origin="Resolved by PSA",
|
origin="psa",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -1567,6 +1567,244 @@ def api_run_checks_create_autotask_ticket():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.get("/api/run-checks/autotask-existing-tickets")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator")
|
||||||
|
def api_run_checks_autotask_existing_tickets():
|
||||||
|
"""List open (non-terminal) Autotask tickets for the selected run's customer.
|
||||||
|
|
||||||
|
Phase 2.2: used by the Run Checks modal to link an existing PSA ticket.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_id = int(request.args.get("run_id") or 0)
|
||||||
|
except Exception:
|
||||||
|
run_id = 0
|
||||||
|
|
||||||
|
q = (request.args.get("q") or "").strip()
|
||||||
|
|
||||||
|
if run_id <= 0:
|
||||||
|
return jsonify({"status": "error", "message": "Invalid parameters."}), 400
|
||||||
|
|
||||||
|
run = JobRun.query.get(run_id)
|
||||||
|
if not run:
|
||||||
|
return jsonify({"status": "error", "message": "Run not found."}), 404
|
||||||
|
|
||||||
|
job = Job.query.get(run.job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"status": "error", "message": "Job not found."}), 404
|
||||||
|
|
||||||
|
customer = Customer.query.get(job.customer_id) if getattr(job, "customer_id", None) else None
|
||||||
|
if not customer:
|
||||||
|
return jsonify({"status": "error", "message": "Customer not found."}), 404
|
||||||
|
|
||||||
|
if not getattr(customer, "autotask_company_id", None):
|
||||||
|
return jsonify({"status": "error", "message": "Customer has no Autotask company mapping."}), 400
|
||||||
|
|
||||||
|
if (getattr(customer, "autotask_mapping_status", None) or "").strip().lower() not in ("ok", "renamed"):
|
||||||
|
return jsonify({"status": "error", "message": "Autotask company mapping is not valid."}), 400
|
||||||
|
|
||||||
|
settings = _get_or_create_settings()
|
||||||
|
|
||||||
|
# Map status ID -> label from cached settings (kept in sync by Settings page).
|
||||||
|
status_map = {}
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
raw = getattr(settings, "autotask_cached_ticket_statuses_json", None)
|
||||||
|
if raw:
|
||||||
|
for x in (_json.loads(raw) or []):
|
||||||
|
if isinstance(x, dict) and "value" in x:
|
||||||
|
status_map[str(x.get("value"))] = str(x.get("label") or "")
|
||||||
|
except Exception:
|
||||||
|
status_map = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _build_autotask_client_from_settings()
|
||||||
|
|
||||||
|
# Ensure we have a status map; if empty, fetch and cache once.
|
||||||
|
if not status_map:
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
statuses = client.get_ticket_statuses()
|
||||||
|
settings.autotask_cached_ticket_statuses_json = _json.dumps(statuses)
|
||||||
|
settings.autotask_reference_last_sync_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
for x in (statuses or []):
|
||||||
|
if isinstance(x, dict) and "value" in x:
|
||||||
|
status_map[str(x.get("value"))] = str(x.get("label") or "")
|
||||||
|
except Exception:
|
||||||
|
# Best-effort; list will still work without labels.
|
||||||
|
pass
|
||||||
|
|
||||||
|
tickets = client.query_tickets_for_company(
|
||||||
|
int(customer.autotask_company_id),
|
||||||
|
search=q,
|
||||||
|
exclude_status_ids=sorted(AUTOTASK_TERMINAL_STATUS_IDS),
|
||||||
|
limit=75,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"status": "error", "message": f"Autotask ticket lookup failed: {exc}"}), 400
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for t in tickets or []:
|
||||||
|
if not isinstance(t, dict):
|
||||||
|
continue
|
||||||
|
tid = t.get("id")
|
||||||
|
tnum = (t.get("ticketNumber") or t.get("number") or "")
|
||||||
|
title = (t.get("title") or "")
|
||||||
|
st = t.get("status")
|
||||||
|
try:
|
||||||
|
st_int = int(st) if st is not None else None
|
||||||
|
except Exception:
|
||||||
|
st_int = None
|
||||||
|
st_label = status_map.get(str(st_int)) if st_int is not None else ""
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": tid,
|
||||||
|
"ticketNumber": str(tnum or ""),
|
||||||
|
"title": str(title or ""),
|
||||||
|
"status": st_int,
|
||||||
|
"statusLabel": st_label or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort: newest-ish first. Autotask query ordering isn't guaranteed, so we provide a stable sort.
|
||||||
|
items.sort(key=lambda x: (x.get("ticketNumber") or ""), reverse=True)
|
||||||
|
|
||||||
|
return jsonify({"status": "ok", "items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/api/run-checks/autotask-link-existing-ticket")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator")
|
||||||
|
def api_run_checks_autotask_link_existing_ticket():
|
||||||
|
"""Link an existing Autotask ticket to the selected run (and propagate to all active runs of the job).
|
||||||
|
|
||||||
|
Phase 2.2: used by the Run Checks modal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_id = int(data.get("run_id") or 0)
|
||||||
|
except Exception:
|
||||||
|
run_id = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
ticket_id = int(data.get("ticket_id") or 0)
|
||||||
|
except Exception:
|
||||||
|
ticket_id = 0
|
||||||
|
|
||||||
|
if run_id <= 0 or ticket_id <= 0:
|
||||||
|
return jsonify({"status": "error", "message": "Invalid parameters."}), 400
|
||||||
|
|
||||||
|
run = JobRun.query.get(run_id)
|
||||||
|
if not run:
|
||||||
|
return jsonify({"status": "error", "message": "Run not found."}), 404
|
||||||
|
|
||||||
|
# Do not overwrite an existing link unless the current one is resolved/deleted.
|
||||||
|
if getattr(run, "autotask_ticket_id", None):
|
||||||
|
return jsonify({"status": "error", "message": "Run already has an Autotask ticket linked."}), 400
|
||||||
|
|
||||||
|
job = Job.query.get(run.job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"status": "error", "message": "Job not found."}), 404
|
||||||
|
|
||||||
|
customer = Customer.query.get(job.customer_id) if getattr(job, "customer_id", None) else None
|
||||||
|
if not customer:
|
||||||
|
return jsonify({"status": "error", "message": "Customer not found."}), 404
|
||||||
|
|
||||||
|
if not getattr(customer, "autotask_company_id", None):
|
||||||
|
return jsonify({"status": "error", "message": "Customer has no Autotask company mapping."}), 400
|
||||||
|
|
||||||
|
if (getattr(customer, "autotask_mapping_status", None) or "").strip().lower() not in ("ok", "renamed"):
|
||||||
|
return jsonify({"status": "error", "message": "Autotask company mapping is not valid."}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _build_autotask_client_from_settings()
|
||||||
|
t = client.get_ticket(ticket_id)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"status": "error", "message": f"Autotask ticket retrieval failed: {exc}"}), 400
|
||||||
|
|
||||||
|
if not isinstance(t, dict):
|
||||||
|
return jsonify({"status": "error", "message": "Autotask did not return a ticket object."}), 400
|
||||||
|
|
||||||
|
# Enforce company scope.
|
||||||
|
try:
|
||||||
|
t_company = int(t.get("companyID") or 0)
|
||||||
|
except Exception:
|
||||||
|
t_company = 0
|
||||||
|
|
||||||
|
if t_company != int(customer.autotask_company_id):
|
||||||
|
return jsonify({"status": "error", "message": "Selected ticket does not belong to the mapped Autotask company."}), 400
|
||||||
|
|
||||||
|
tnum = (t.get("ticketNumber") or t.get("number") or "")
|
||||||
|
tnum = str(tnum or "").strip()
|
||||||
|
if not tnum:
|
||||||
|
return jsonify({"status": "error", "message": "Autotask ticket does not have a ticket number."}), 400
|
||||||
|
|
||||||
|
# Block terminal tickets from being linked (Phase 2.2 only lists open tickets, but enforce server-side).
|
||||||
|
try:
|
||||||
|
st = int(t.get("status")) if t.get("status") is not None else 0
|
||||||
|
except Exception:
|
||||||
|
st = 0
|
||||||
|
if st in AUTOTASK_TERMINAL_STATUS_IDS:
|
||||||
|
return jsonify({"status": "error", "message": "Cannot link a terminal/completed Autotask ticket."}), 400
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
run.autotask_ticket_id = int(ticket_id)
|
||||||
|
run.autotask_ticket_number = tnum
|
||||||
|
run.autotask_ticket_created_at = now
|
||||||
|
run.autotask_ticket_created_by_user_id = current_user.id
|
||||||
|
|
||||||
|
# Propagate linkage to all active (unreviewed) runs of the same job.
|
||||||
|
active_runs = (
|
||||||
|
JobRun.query.filter(JobRun.job_id == job.id, JobRun.reviewed_at.is_(None)).order_by(JobRun.id.asc()).all()
|
||||||
|
)
|
||||||
|
run_ids = []
|
||||||
|
for rr in active_runs or []:
|
||||||
|
if getattr(rr, "id", None) is None:
|
||||||
|
continue
|
||||||
|
rr.autotask_ticket_id = int(ticket_id)
|
||||||
|
rr.autotask_ticket_number = tnum
|
||||||
|
if getattr(rr, "autotask_ticket_created_at", None) is None:
|
||||||
|
rr.autotask_ticket_created_at = now
|
||||||
|
if getattr(rr, "autotask_ticket_created_by_user_id", None) is None:
|
||||||
|
rr.autotask_ticket_created_by_user_id = current_user.id
|
||||||
|
run_ids.append(int(rr.id))
|
||||||
|
|
||||||
|
# Ensure internal Ticket + TicketJobRun linkage for legacy ticket behavior.
|
||||||
|
internal_ticket = None
|
||||||
|
try:
|
||||||
|
internal_ticket = _ensure_internal_ticket_for_autotask(
|
||||||
|
ticket_number=tnum,
|
||||||
|
job=job,
|
||||||
|
run_ids=run_ids,
|
||||||
|
now=now,
|
||||||
|
active_from_dt=now,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
internal_ticket = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"status": "error", "message": "Failed to persist Autotask ticket link."}), 500
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"ticket_id": int(ticket_id),
|
||||||
|
"ticket_number": tnum,
|
||||||
|
"internal_ticket_id": int(getattr(internal_ticket, "id", 0) or 0) if internal_ticket else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
@main_bp.post("/api/run-checks/mark-reviewed")
|
@main_bp.post("/api/run-checks/mark-reviewed")
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator")
|
@roles_required("admin", "operator")
|
||||||
|
|||||||
@ -219,7 +219,10 @@
|
|||||||
{% if autotask_enabled %}
|
{% 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>
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="rcm_autotask_link_existing">Link existing</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
@ -292,6 +295,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="modal fade" id="autotaskLinkModal" tabindex="-1" aria-labelledby="autotaskLinkModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="autotaskLinkModalLabel">Link existing Autotask ticket</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<input type="text" class="form-control" id="atl_search" placeholder="Search by ticket number or title" />
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="atl_refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mb-2" id="atl_status"></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 1%;"></th>
|
||||||
|
<th>Ticket</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="atl_tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted" id="atl_empty" style="display:none;">No tickets found.</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var table = document.getElementById('runChecksTable');
|
var table = document.getElementById('runChecksTable');
|
||||||
@ -851,6 +891,7 @@ table.addEventListener('change', function (e) {
|
|||||||
|
|
||||||
function bindInlineCreateForms() {
|
function bindInlineCreateForms() {
|
||||||
var btnAutotask = document.getElementById('rcm_autotask_create');
|
var btnAutotask = document.getElementById('rcm_autotask_create');
|
||||||
|
var btnAutotaskLink = document.getElementById('rcm_autotask_link_existing');
|
||||||
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');
|
||||||
|
|
||||||
@ -870,6 +911,7 @@ table.addEventListener('change', function (e) {
|
|||||||
|
|
||||||
function setDisabled(disabled) {
|
function setDisabled(disabled) {
|
||||||
if (btnAutotask) btnAutotask.disabled = disabled;
|
if (btnAutotask) btnAutotask.disabled = disabled;
|
||||||
|
if (btnAutotaskLink) btnAutotaskLink.disabled = disabled;
|
||||||
if (btnTicket) btnTicket.disabled = disabled;
|
if (btnTicket) btnTicket.disabled = disabled;
|
||||||
if (tCode) tCode.disabled = disabled;
|
if (tCode) tCode.disabled = disabled;
|
||||||
if (btnRemark) btnRemark.disabled = disabled;
|
if (btnRemark) btnRemark.disabled = disabled;
|
||||||
@ -915,6 +957,12 @@ table.addEventListener('change', function (e) {
|
|||||||
if (run && run.autotask_ticket_id && (isResolved || isDeleted)) btnAutotask.textContent = 'Create new';
|
if (run && run.autotask_ticket_id && (isResolved || isDeleted)) btnAutotask.textContent = 'Create new';
|
||||||
else btnAutotask.textContent = 'Create';
|
else btnAutotask.textContent = 'Create';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (btnAutotaskLink) {
|
||||||
|
var hasAtTicket = !!(run && run.autotask_ticket_id);
|
||||||
|
// Link existing is only meaningful when there is no PSA ticket linked to this run.
|
||||||
|
btnAutotaskLink.style.display = hasAtTicket ? 'none' : '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.__rcmRenderAutotaskInfo = renderAutotaskInfo;
|
window.__rcmRenderAutotaskInfo = renderAutotaskInfo;
|
||||||
|
|
||||||
@ -963,6 +1011,114 @@ table.addEventListener('change', function (e) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (btnAutotaskLink) {
|
||||||
|
var linkModalEl = document.getElementById('autotaskLinkModal');
|
||||||
|
var linkModal = linkModalEl ? bootstrap.Modal.getOrCreateInstance(linkModalEl) : null;
|
||||||
|
var atlSearch = document.getElementById('atl_search');
|
||||||
|
var atlRefresh = document.getElementById('atl_refresh');
|
||||||
|
var atlStatus = document.getElementById('atl_status');
|
||||||
|
var atlTbody = document.getElementById('atl_tbody');
|
||||||
|
var atlEmpty = document.getElementById('atl_empty');
|
||||||
|
|
||||||
|
function renderAtlRows(items) {
|
||||||
|
if (!atlTbody) return;
|
||||||
|
atlTbody.innerHTML = '';
|
||||||
|
if (atlEmpty) atlEmpty.style.display = (items && items.length) ? 'none' : '';
|
||||||
|
(items || []).forEach(function (t) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var tdBtn = document.createElement('td');
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-sm btn-outline-primary';
|
||||||
|
btn.textContent = 'Link';
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||||
|
if (!confirm('Link ticket ' + (t.ticketNumber || '') + ' to this run?')) return;
|
||||||
|
if (atlStatus) atlStatus.textContent = 'Linking...';
|
||||||
|
apiJson('/api/run-checks/autotask-link-existing-ticket', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({run_id: currentRunId, ticket_id: t.id})
|
||||||
|
})
|
||||||
|
.then(function (j) {
|
||||||
|
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
|
||||||
|
if (atlStatus) atlStatus.textContent = '';
|
||||||
|
if (linkModal) linkModal.hide();
|
||||||
|
|
||||||
|
// Refresh modal data so UI reflects stored ticket linkage.
|
||||||
|
var keepRunId = currentRunId;
|
||||||
|
if (currentJobId) {
|
||||||
|
return fetch('/api/run-checks/details?job_id=' + encodeURIComponent(currentJobId))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (payload) {
|
||||||
|
currentPayload = payload;
|
||||||
|
var idx = 0;
|
||||||
|
var runs = (payload && payload.runs) || [];
|
||||||
|
for (var i = 0; i < runs.length; i++) {
|
||||||
|
if (String(runs[i].id) === String(keepRunId)) { idx = i; break; }
|
||||||
|
}
|
||||||
|
renderRun(payload, idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
if (atlStatus) atlStatus.textContent = e.message || 'Failed.';
|
||||||
|
else alert(e.message || 'Failed.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tdBtn.appendChild(btn);
|
||||||
|
|
||||||
|
var tdNum = document.createElement('td');
|
||||||
|
tdNum.textContent = (t.ticketNumber || '');
|
||||||
|
|
||||||
|
var tdTitle = document.createElement('td');
|
||||||
|
tdTitle.textContent = (t.title || '');
|
||||||
|
|
||||||
|
var tdStatus = document.createElement('td');
|
||||||
|
tdStatus.textContent = (t.status_label || String(t.status || ''));
|
||||||
|
|
||||||
|
tr.appendChild(tdBtn);
|
||||||
|
tr.appendChild(tdNum);
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
tr.appendChild(tdStatus);
|
||||||
|
atlTbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadExistingTickets() {
|
||||||
|
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||||
|
var q = atlSearch ? (atlSearch.value || '').trim() : '';
|
||||||
|
if (atlStatus) atlStatus.textContent = 'Loading...';
|
||||||
|
fetch('/api/run-checks/autotask-existing-tickets?run_id=' + encodeURIComponent(currentRunId) + '&q=' + encodeURIComponent(q))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (j) {
|
||||||
|
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
|
||||||
|
if (atlStatus) atlStatus.textContent = '';
|
||||||
|
renderAtlRows(j.items || []);
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
if (atlStatus) atlStatus.textContent = e.message || 'Failed.';
|
||||||
|
renderAtlRows([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atlRefresh) {
|
||||||
|
atlRefresh.addEventListener('click', function () { loadExistingTickets(); });
|
||||||
|
}
|
||||||
|
if (atlSearch) {
|
||||||
|
atlSearch.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter') { ev.preventDefault(); loadExistingTickets(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
btnAutotaskLink.addEventListener('click', function () {
|
||||||
|
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||||
|
if (atlStatus) atlStatus.textContent = '';
|
||||||
|
renderAtlRows([]);
|
||||||
|
if (linkModal) linkModal.show();
|
||||||
|
loadExistingTickets();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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; }
|
||||||
|
|||||||
@ -422,6 +422,18 @@ Changes:
|
|||||||
- Preserved legacy internal Ticket and TicketJobRun creation/linking so Tickets overview, Tickets/Remarks, and Job Details continue to function identically to manually linked tickets.
|
- Preserved legacy internal Ticket and TicketJobRun creation/linking so Tickets overview, Tickets/Remarks, and Job Details continue to function identically to manually linked tickets.
|
||||||
- Ensured resolution timestamps are derived from Autotask (resolvedDateTime / completedDate) instead of using current time.
|
- Ensured resolution timestamps are derived from Autotask (resolvedDateTime / completedDate) instead of using current time.
|
||||||
|
|
||||||
|
## v20260120-08-runchecks-link-existing-autotask-ticket
|
||||||
|
|
||||||
|
- Added Phase 2.2 “Link existing Autotask ticket” flow in Run Checks:
|
||||||
|
- New UI button “Link existing” next to “Create” in the Run Checks modal.
|
||||||
|
- Added modal popup with search + refresh and a selectable list of non-terminal tickets.
|
||||||
|
- Added backend API endpoints:
|
||||||
|
- GET /api/run-checks/autotask-existing-tickets (list/search tickets for mapped company, excluding terminal statuses)
|
||||||
|
- POST /api/run-checks/autotask-link-existing-ticket (link selected ticket to run and create/update internal Ticket + TicketJobRun links)
|
||||||
|
- Extended Autotask client with a ticket query helper to support listing/searching tickets for a company.
|
||||||
|
- Improved internal ticket resolve handling:
|
||||||
|
- Do not overwrite resolved_origin when already set; keep “psa” origin when resolved by PSA.
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
## v0.1.21
|
## v0.1.21
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user