Auto-commit local changes before build (2026-01-20 13:10:45)

This commit is contained in:
Ivo Oskamp 2026-01-20 13:10:45 +01:00
parent fc0cf1ef96
commit 92c67805e5
5 changed files with 467 additions and 5 deletions

View File

@ -1 +1 @@
v20260120-07-autotask-psa-resolution-handling
v20260120-08-runchecks-link-existing-autotask-ticket

View File

@ -601,3 +601,59 @@ class AutotaskClient:
data = self._request("POST", "Tickets/query", json_body={"filter": flt})
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

View File

@ -427,8 +427,8 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None:
else:
resolved_at_raw = None
if resolved_at_raw:
s = str(resolved_at_raw).replace("Z", "+00:00")
resolved_at = datetime.fromisoformat(s)
s_dt = str(resolved_at_raw).replace("Z", "+00:00")
resolved_at = datetime.fromisoformat(s_dt)
if resolved_at.tzinfo is not None:
resolved_at = resolved_at.astimezone(timezone.utc).replace(tzinfo=None)
except Exception:
@ -446,7 +446,7 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None:
job=job,
run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)],
now=resolved_at or now,
origin="Resolved by PSA",
origin="psa",
)
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")
@login_required
@roles_required("admin", "operator")

View File

@ -219,8 +219,11 @@
{% if autotask_enabled %}
<div class="d-flex align-items-center justify-content-between">
<div class="fw-semibold">Autotask ticket</div>
<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 class="mt-2 small" id="rcm_autotask_info"></div>
<div class="mt-2 small text-muted" id="rcm_autotask_status"></div>
{% else %}
@ -292,6 +295,43 @@
</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>
(function () {
var table = document.getElementById('runChecksTable');
@ -851,6 +891,7 @@ table.addEventListener('change', function (e) {
function bindInlineCreateForms() {
var btnAutotask = document.getElementById('rcm_autotask_create');
var btnAutotaskLink = document.getElementById('rcm_autotask_link_existing');
var atInfo = document.getElementById('rcm_autotask_info');
var atStatus = document.getElementById('rcm_autotask_status');
@ -870,6 +911,7 @@ table.addEventListener('change', function (e) {
function setDisabled(disabled) {
if (btnAutotask) btnAutotask.disabled = disabled;
if (btnAutotaskLink) btnAutotaskLink.disabled = disabled;
if (btnTicket) btnTicket.disabled = disabled;
if (tCode) tCode.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';
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;
@ -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) {
btnAutotask.addEventListener('click', function () {
if (!currentRunId) { alert('Select a run first.'); return; }

View File

@ -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.
- 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