Compare commits

...

33 Commits

Author SHA1 Message Date
f8a57efee0 Auto-commit local changes before build (2026-01-16 16:24:35) 2026-01-16 16:24:35 +01:00
46cc5b10ab Auto-commit local changes before build (2026-01-16 16:15:43) 2026-01-16 16:15:43 +01:00
4c18365753 Auto-commit local changes before build (2026-01-16 15:39:16) 2026-01-16 15:39:16 +01:00
4def0aad46 Auto-commit local changes before build (2026-01-16 15:38:11) 2026-01-16 15:38:11 +01:00
9025d70b8e Auto-commit local changes before build (2026-01-16 14:13:31) 2026-01-16 14:13:31 +01:00
ef8d12065b Auto-commit local changes before build (2026-01-16 13:44:34) 2026-01-16 13:44:34 +01:00
25d1962f7b Auto-commit local changes before build (2026-01-16 13:31:20) 2026-01-16 13:31:20 +01:00
487f923064 Auto-commit local changes before build (2026-01-16 13:17:06) 2026-01-16 13:17:06 +01:00
f780bbc399 Auto-commit local changes before build (2026-01-16 12:56:34) 2026-01-16 12:56:34 +01:00
b46b7fbc21 Auto-commit local changes before build (2026-01-16 12:28:07) 2026-01-16 12:28:07 +01:00
9399082231 Auto-commit local changes before build (2026-01-16 10:29:40) 2026-01-16 10:29:40 +01:00
8a16ff010f Auto-commit local changes before build (2026-01-16 10:07:17) 2026-01-16 10:07:17 +01:00
748769afc0 Auto-commit local changes before build (2026-01-16 10:01:42) 2026-01-16 10:01:42 +01:00
abb6780744 Auto-commit local changes before build (2026-01-16 09:04:12) 2026-01-16 09:04:12 +01:00
83a29a7a3c Auto-commit local changes before build (2026-01-15 16:31:32) 2026-01-15 16:31:32 +01:00
66f5a57fe0 Auto-commit local changes before build (2026-01-15 16:17:26) 2026-01-15 16:17:26 +01:00
473044bd67 Auto-commit local changes before build (2026-01-15 16:02:52) 2026-01-15 16:02:52 +01:00
afd45cc568 Auto-commit local changes before build (2026-01-15 15:19:37) 2026-01-15 15:19:37 +01:00
3564bcf62f Auto-commit local changes before build (2026-01-15 15:05:42) 2026-01-15 15:05:42 +01:00
49fd29a6f2 Auto-commit local changes before build (2026-01-15 14:36:50) 2026-01-15 14:36:50 +01:00
49f6d41715 Auto-commit local changes before build (2026-01-15 14:24:54) 2026-01-15 14:24:54 +01:00
186807b098 Auto-commit local changes before build (2026-01-15 14:14:29) 2026-01-15 14:14:29 +01:00
c68b401709 Auto-commit local changes before build (2026-01-15 14:08:59) 2026-01-15 14:08:59 +01:00
5b9b6f4c38 Auto-commit local changes before build (2026-01-15 13:45:53) 2026-01-15 13:45:53 +01:00
981d65c274 Auto-commit local changes before build (2026-01-15 12:44:01) 2026-01-15 12:44:01 +01:00
1a2ca59d16 Auto-commit local changes before build (2026-01-15 12:31:08) 2026-01-15 12:31:08 +01:00
83d487a206 Auto-commit local changes before build (2026-01-15 11:52:52) 2026-01-15 11:52:52 +01:00
490ab1ae34 Auto-commit local changes before build (2026-01-15 11:10:13) 2026-01-15 11:10:13 +01:00
1a64627a4e Auto-commit local changes before build (2026-01-15 10:40:40) 2026-01-15 10:40:40 +01:00
d5fdc9a8d9 Auto-commit local changes before build (2026-01-15 10:21:30) 2026-01-15 10:21:30 +01:00
f6310da575 Auto-commit local changes before build (2026-01-15 10:12:09) 2026-01-15 10:12:09 +01:00
48e7830957 Auto-commit local changes before build (2026-01-15 09:37:33) 2026-01-15 09:37:33 +01:00
777a9b4b31 Auto-commit local changes before build (2026-01-13 17:16:20) 2026-01-13 17:16:20 +01:00
18 changed files with 4274 additions and 47 deletions

View File

@ -1 +1 @@
v20260113-08-vspc-object-linking-normalize v20260116-12-autotask-ticket-sync-circular-import-fix

View File

@ -0,0 +1,784 @@
import json
import logging
import os
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import requests
logger = logging.getLogger(__name__)
@dataclass
class AutotaskZoneInfo:
zone_name: str
api_url: str
web_url: Optional[str] = None
ci: Optional[int] = None
class AutotaskError(RuntimeError):
def __init__(self, message: str, status_code: Optional[int] = None) -> None:
super().__init__(message)
self.status_code = status_code
class AutotaskClient:
def __init__(
self,
username: str,
password: str,
api_integration_code: str,
environment: str = "production",
timeout_seconds: int = 30,
) -> None:
self.username = username
self.password = password
self.api_integration_code = api_integration_code
self.environment = (environment or "production").strip().lower()
self.timeout_seconds = timeout_seconds
self._zone_info: Optional[AutotaskZoneInfo] = None
self._zoneinfo_base_used: Optional[str] = None
def _debug_enabled(self) -> bool:
"""Return True when verbose Autotask integration logging is enabled.
This is intentionally controlled via an environment variable to avoid
writing sensitive payloads to logs by default.
"""
return str(os.getenv("BACKUPCHECKS_AUTOTASK_DEBUG", "")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _safe_json_preview(self, data: Any, max_chars: int = 1200) -> str:
"""Serialize JSON-like data for logging, truncating large payloads."""
try:
s = json.dumps(data, ensure_ascii=False, default=str)
except Exception:
s = str(data)
if len(s) > max_chars:
return s[:max_chars] + ""
return s
def _zoneinfo_bases(self) -> List[str]:
"""Return a list of zoneInformation base URLs to try.
Autotask tenants can behave differently for Sandbox vs Production.
To keep connection testing reliable, we try the expected base first
and fall back to the alternative if needed.
"""
prod = "https://webservices.autotask.net/atservicesrest"
sb = "https://webservices2.autotask.net/atservicesrest"
if self.environment == "sandbox":
return [sb, prod]
return [prod, sb]
def get_zone_info(self) -> AutotaskZoneInfo:
if self._zone_info is not None:
return self._zone_info
last_error: Optional[str] = None
data: Optional[Dict[str, Any]] = None
for base in self._zoneinfo_bases():
url = f"{base.rstrip('/')}/v1.0/zoneInformation"
params = {"user": self.username}
try:
resp = requests.get(url, params=params, timeout=self.timeout_seconds)
except Exception as exc:
last_error = f"ZoneInformation request failed for {base}: {exc}"
continue
if resp.status_code >= 400:
last_error = f"ZoneInformation request failed for {base} (HTTP {resp.status_code})."
continue
try:
data = resp.json()
except Exception:
last_error = f"ZoneInformation response from {base} is not valid JSON."
continue
self._zoneinfo_base_used = base
break
if data is None:
raise AutotaskError(last_error or "ZoneInformation request failed.")
zone = AutotaskZoneInfo(
zone_name=str(data.get("zoneName") or ""),
api_url=str(data.get("url") or "").rstrip("/"),
web_url=(str(data.get("webUrl") or "").rstrip("/") or None),
ci=(int(data["ci"]) if str(data.get("ci") or "").isdigit() else None),
)
if not zone.api_url:
raise AutotaskError("ZoneInformation did not return an API URL.")
self._zone_info = zone
return zone
def _headers(self) -> Dict[str, str]:
# Autotask REST API requires the ApiIntegrationCode header.
# Some tenants/proxies appear picky despite headers being case-insensitive,
# so we include both common casings for maximum compatibility.
return {
"ApiIntegrationCode": self.api_integration_code,
"APIIntegrationcode": self.api_integration_code,
"Content-Type": "application/json",
"Accept": "application/json",
}
def _request_raw(
self,
method: str,
path: str,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
) -> requests.Response:
"""Perform an Autotask REST API request and return the raw response."""
zone = self.get_zone_info()
base = zone.api_url.rstrip("/")
url = f"{base}/v1.0/{path.lstrip('/')}"
headers = self._headers()
def do_request(use_basic_auth: bool, extra_headers: Optional[Dict[str, str]] = None) -> requests.Response:
h = dict(headers)
if extra_headers:
h.update(extra_headers)
return requests.request(
method=method.upper(),
url=url,
headers=h,
params=params or None,
json=json_body if json_body is not None else None,
auth=(self.username, self.password) if use_basic_auth else None,
timeout=self.timeout_seconds,
)
try:
# Primary auth method: HTTP Basic (username + API secret)
resp = do_request(use_basic_auth=True)
# Compatibility fallback: some environments accept credentials only via headers.
if resp.status_code == 401:
resp = do_request(
use_basic_auth=False,
extra_headers={"UserName": self.username, "Secret": self.password},
)
except Exception as exc:
raise AutotaskError(f"Request failed: {exc}") from exc
if resp.status_code == 401:
zi_base = self._zoneinfo_base_used or "unknown"
raise AutotaskError(
"Authentication failed (HTTP 401). "
"Verify API Username, API Secret, and ApiIntegrationCode. "
f"Environment={self.environment}, ZoneInfoBase={zi_base}, ZoneApiUrl={zone.api_url}.",
status_code=401,
)
if resp.status_code == 403:
raise AutotaskError(
"Access forbidden (HTTP 403). API user permissions may be insufficient.",
status_code=403,
)
if resp.status_code == 404:
raise AutotaskError(f"Resource not found (HTTP 404) for path: {path}", status_code=404)
if resp.status_code >= 400:
raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).", status_code=resp.status_code)
return resp
def _request(
self,
method: str,
path: str,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
) -> Any:
resp = self._request_raw(method=method, path=path, params=params, json_body=json_body)
if not (resp.content or b""):
return {}
try:
return resp.json()
except Exception as exc:
raise AutotaskError("Autotask API response is not valid JSON.") from exc
def _as_items_list(self, payload: Any) -> List[Dict[str, Any]]:
"""Normalize common Autotask REST payload shapes to a list of dicts."""
if payload is None:
return []
if isinstance(payload, list):
return [x for x in payload if isinstance(x, dict)]
if isinstance(payload, dict):
items = payload.get("items")
if isinstance(items, list):
return [x for x in items if isinstance(x, dict)]
fields = payload.get("fields")
if isinstance(fields, list):
return [x for x in fields if isinstance(x, dict)]
# Some endpoints may return a single object.
if "id" in payload:
return [payload]
return []
def _get_collection(self, resource_name: str) -> List[Dict[str, Any]]:
"""Fetch a reference collection via GET /<resource>.
Note: Not all Autotask entities support /query. Reference data like Queues and
TicketSources is typically retrieved via a simple collection GET.
"""
data = self._request("GET", resource_name)
return self._as_items_list(data)
def _get_entity_fields(self, entity_name: str) -> List[Dict[str, Any]]:
data = self._request("GET", f"{entity_name}/entityInformation/fields")
return self._as_items_list(data)
def _call_picklist_values(self, picklist_values_path: str) -> List[Dict[str, Any]]:
# picklistValues path can be returned as a full URL or as a relative path.
path = (picklist_values_path or "").strip()
if not path:
return []
# If a full URL is returned, strip everything up to /v1.0/
if "/v1.0/" in path:
path = path.split("/v1.0/", 1)[1]
# If it includes the base API URL without /v1.0, strip to resource path.
if "/atservicesrest/" in path and "/v1.0/" not in picklist_values_path:
# Fallback: attempt to strip after atservicesrest/
path = path.split("/atservicesrest/", 1)[1]
if path.startswith("v1.0/"):
path = path.split("v1.0/", 1)[1]
data = self._request("GET", path)
return self._as_items_list(data)
def get_queues(self) -> List[Dict[str, Any]]:
"""Return Ticket Queue picklist values.
Autotask does not expose a universal top-level Queues entity in all tenants.
The reliable source is the Tickets.queueID picklist metadata.
"""
return self._get_ticket_picklist_values(field_names=["queueid", "queue"])
def get_ticket_sources(self) -> List[Dict[str, Any]]:
"""Return Ticket Source picklist values.
Similar to queues, Ticket Source values are best retrieved via the
Tickets.source picklist metadata to avoid relying on optional entities.
"""
return self._get_ticket_picklist_values(field_names=["source", "sourceid"])
def search_companies(self, query: str, limit: int = 25) -> List[Dict[str, Any]]:
"""Search Companies by company name.
Uses the standard REST query endpoint:
GET /Companies/query?search={...}
Returns a minimal list of dicts with keys: id, companyName, isActive.
"""
q = (query or "").strip()
if not q:
return []
# Keep payload small and predictable.
# Field names in filters are case-insensitive in many tenants, but the docs
# commonly show CompanyName.
search_payload: Dict[str, Any] = {
"filter": [
{"op": "contains", "field": "CompanyName", "value": q},
],
"maxRecords": int(limit) if int(limit) > 0 else 25,
}
params = {"search": json.dumps(search_payload)}
data = self._request("GET", "Companies/query", params=params)
items = self._as_items_list(data)
out: List[Dict[str, Any]] = []
for it in items:
if not isinstance(it, dict):
continue
cid = it.get("id")
name = it.get("companyName") or it.get("CompanyName") or ""
try:
cid_int = int(cid)
except Exception:
continue
out.append(
{
"id": cid_int,
"companyName": str(name),
"isActive": bool(it.get("isActive", True)),
}
)
out.sort(key=lambda x: (x.get("companyName") or "").lower())
return out
def get_company(self, company_id: int) -> Dict[str, Any]:
"""Fetch a single Company by ID."""
return self._request("GET", f"Companies/{int(company_id)}")
def _get_ticket_picklist_values(self, field_names: List[str]) -> List[Dict[str, Any]]:
"""Retrieve picklist values for a Tickets field.
Autotask field metadata can vary between tenants/environments.
We first try exact name matches, then fall back to a contains-match
on the metadata field name/label for picklist fields.
"""
fields = self._get_entity_fields("Tickets")
wanted = {n.strip().lower() for n in (field_names or []) if str(n).strip()}
def _field_label(f: Dict[str, Any]) -> str:
# Autotask metadata commonly provides either "label" or "displayName".
return str(f.get("label") or f.get("displayName") or "").strip().lower()
field: Optional[Dict[str, Any]] = None
# 1) Exact name match
for f in fields:
name = str(f.get("name") or "").strip().lower()
if name in wanted:
field = f
break
# 2) Fallback: contains match for picklists (handles QueueID vs TicketQueueID etc.)
if field is None and wanted:
candidates: List[Dict[str, Any]] = []
for f in fields:
if not bool(f.get("isPickList")):
continue
name = str(f.get("name") or "").strip().lower()
label = _field_label(f)
if any(w in name for w in wanted) or any(w in label for w in wanted):
candidates.append(f)
if candidates:
# Prefer the most specific/shortest name match to avoid overly broad matches.
candidates.sort(key=lambda x: len(str(x.get("name") or "")))
field = candidates[0]
if not field:
raise AutotaskError(
"Unable to locate Tickets field metadata for picklist retrieval: "
f"{sorted(wanted)}"
)
if not bool(field.get("isPickList")):
raise AutotaskError(f"Tickets.{field.get('name')} is not marked as a picklist in Autotask metadata.")
picklist_values = field.get("picklistValues")
# Autotask may return picklist values inline (as a list) or as a URL/path.
if isinstance(picklist_values, list):
return [x for x in picklist_values if isinstance(x, dict)]
if not isinstance(picklist_values, str) or not picklist_values.strip():
raise AutotaskError(f"Tickets.{field.get('name')} metadata did not include picklist values.")
return self._call_picklist_values(picklist_values)
def get_ticket_priorities(self) -> List[Dict[str, Any]]:
"""Return Ticket Priority picklist values.
We intentionally retrieve this from entity metadata to prevent hardcoded priority IDs.
"""
fields = self._get_entity_fields("Tickets")
priority_field: Optional[Dict[str, Any]] = None
def _field_label(f: Dict[str, Any]) -> str:
return str(f.get("label") or f.get("displayName") or "").strip().lower()
# Exact match first
for f in fields:
name = str(f.get("name") or "").strip().lower()
if name == "priority":
priority_field = f
break
# Fallback: contains match (handles variations like TicketPriority)
if priority_field is None:
candidates: List[Dict[str, Any]] = []
for f in fields:
if not bool(f.get("isPickList")):
continue
name = str(f.get("name") or "").strip().lower()
label = _field_label(f)
if "priority" in name or "priority" in label:
candidates.append(f)
if candidates:
candidates.sort(key=lambda x: len(str(x.get("name") or "")))
priority_field = candidates[0]
if not priority_field:
raise AutotaskError("Unable to locate a Tickets priority picklist field in Autotask metadata.")
if not bool(priority_field.get("isPickList")):
raise AutotaskError("Tickets.priority is not marked as a picklist in Autotask metadata.")
picklist_values = priority_field.get("picklistValues")
if isinstance(picklist_values, list):
return [x for x in picklist_values if isinstance(x, dict)]
if not isinstance(picklist_values, str) or not picklist_values.strip():
raise AutotaskError("Tickets.priority metadata did not include picklist values.")
return self._call_picklist_values(picklist_values)
def get_ticket_statuses(self) -> List[Dict[str, Any]]:
"""Return Ticket Status picklist values.
We retrieve this from Tickets field metadata to avoid hardcoded status IDs.
"""
return self._get_ticket_picklist_values(field_names=["status", "statusid"])
def get_ticket(self, ticket_id: int) -> Dict[str, Any]:
"""Fetch a Ticket by ID via GET /Tickets/<id>."""
if not isinstance(ticket_id, int) or ticket_id <= 0:
raise AutotaskError("Invalid Autotask ticket id.")
data = self._request("GET", f"Tickets/{ticket_id}")
# Autotask commonly wraps single-entity GET results in an "item" object.
# Normalize to the entity dict so callers can access fields like "id" and
# "ticketNumber" without having to unwrap.
if isinstance(data, dict) and data:
if isinstance(data.get("item"), dict) and data.get("item"):
return data["item"]
# Some endpoints/tenants may return a list even for a single ID.
if isinstance(data.get("items"), list) and data.get("items"):
first = data.get("items")[0]
if isinstance(first, dict) and first:
return first
return data
raise AutotaskError("Autotask did not return a ticket object.")
def query_tickets_by_ids(
self,
ticket_ids: List[int],
*,
max_records_per_query: int = 200,
corr_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Fetch multiple Tickets by id.
Preferred path:
- Use GET Tickets/query with an 'in' filter over id.
Fallback path:
- If the tenant does not support 'in' queries, fetch tickets individually
via GET Tickets/<id>.
Returns a list of ticket objects (dicts) for tickets that exist.
"""
# Normalize ids
ids: List[int] = []
for x in ticket_ids or []:
try:
xi = int(x)
except Exception:
continue
if xi > 0:
ids.append(xi)
# De-duplicate while preserving order
seen = set()
dedup: List[int] = []
for xi in ids:
if xi in seen:
continue
seen.add(xi)
dedup.append(xi)
if not dedup:
return []
corr = corr_id or uuid.uuid4().hex[:10]
def _chunk(lst: List[int], n: int) -> List[List[int]]:
return [lst[i : i + n] for i in range(0, len(lst), n)]
out: List[Dict[str, Any]] = []
# Try query with op=in first (chunked)
try:
for chunk in _chunk(dedup, max(1, int(max_records_per_query))):
search_payload: Dict[str, Any] = {
"filter": [
{"op": "in", "field": "id", "value": chunk},
],
"maxRecords": len(chunk),
}
params = {"search": json.dumps(search_payload)}
if self._debug_enabled():
logger.info(
"[autotask][%s] Tickets/query ids payload=%s",
corr,
self._safe_json_preview(search_payload, max_chars=1200),
)
data = self._request("GET", "Tickets/query", params=params)
items = self._as_items_list(data)
for it in items:
if isinstance(it, dict) and it:
out.append(it)
return out
except AutotaskError as exc:
# Common tenant behavior: reject op=in with HTTP 400.
if self._debug_enabled():
logger.info(
"[autotask][%s] Tickets/query ids op=in failed; falling back to per-ticket GET. error=%s",
corr,
str(exc),
)
except Exception as exc:
if self._debug_enabled():
logger.info(
"[autotask][%s] Tickets/query ids unexpected error; falling back. error=%s",
corr,
str(exc),
)
# Fallback: individual GET calls (best-effort)
for tid in dedup:
try:
t = self.get_ticket(int(tid))
if isinstance(t, dict) and t:
out.append(t)
except AutotaskError as exc:
# 404 -> deleted/missing ticket, ignore
if getattr(exc, "status_code", None) == 404:
continue
# Any other error: continue best-effort
continue
except Exception:
continue
return out
def _lookup_created_ticket_id(
self,
tracking_identifier: str,
company_id: Optional[int] = None,
corr_id: Optional[str] = None,
) -> Optional[int]:
"""Lookup the most recently created ticket by tracking identifier.
Some Autotask tenants return an empty body and omit Location headers on
successful POST /Tickets calls. In that case, we must lookup the created
ticket deterministically via query.
We prefer filtering by CompanyID when available to reduce ambiguity.
"""
tid = (tracking_identifier or "").strip()
if not tid:
return None
filters: List[Dict[str, Any]] = [
{"op": "eq", "field": "TrackingIdentifier", "value": tid},
]
if isinstance(company_id, int) and company_id > 0:
filters.append({"op": "eq", "field": "CompanyID", "value": int(company_id)})
# Order by createDate desc when supported; fall back to id desc.
search_payload: Dict[str, Any] = {
"filter": filters,
"maxRecords": 1,
"orderby": [
{"field": "createDate", "direction": "desc"},
{"field": "id", "direction": "desc"},
],
}
params = {"search": json.dumps(search_payload)}
if self._debug_enabled():
logger.info(
"[autotask][%s] Tickets/query lookup payload=%s",
corr_id or "-",
self._safe_json_preview(search_payload, max_chars=1200),
)
data = self._request("GET", "Tickets/query", params=params)
items = self._as_items_list(data)
if self._debug_enabled():
logger.info(
"[autotask][%s] Tickets/query lookup result_count=%s keys=%s",
corr_id or "-",
len(items),
(sorted(list(items[0].keys())) if items and isinstance(items[0], dict) else None),
)
if not items:
return None
first = items[0]
if isinstance(first, dict) and str(first.get("id") or "").isdigit():
return int(first["id"])
return None
def create_ticket(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Create a Ticket in Autotask.
Uses POST /Tickets.
Returns the created ticket object (as returned by Autotask).
"""
if not isinstance(payload, dict) or not payload:
raise AutotaskError("Ticket payload is empty.")
corr_id = uuid.uuid4().hex[:10]
if self._debug_enabled():
# Avoid dumping full descriptions by default, but include key routing fields.
payload_keys = sorted(list(payload.keys()))
logger.info(
"[autotask][%s] POST /Tickets payload_keys=%s companyID=%s queueID=%s source=%s status=%s priority=%s trackingIdentifier=%s",
corr_id,
payload_keys,
payload.get("companyID") or payload.get("CompanyID") or payload.get("companyId"),
payload.get("queueID") or payload.get("QueueID") or payload.get("queueId"),
payload.get("source") or payload.get("Source") or payload.get("sourceId") or payload.get("sourceID"),
payload.get("status") or payload.get("Status") or payload.get("statusId") or payload.get("statusID"),
payload.get("priority") or payload.get("Priority"),
payload.get("trackingIdentifier") or payload.get("TrackingIdentifier"),
)
resp = self._request_raw("POST", "Tickets", json_body=payload)
if self._debug_enabled():
location = (resp.headers.get("Location") or resp.headers.get("location") or "").strip()
logger.info(
"[autotask][%s] POST /Tickets response http=%s content_type=%s content_length=%s location=%s",
corr_id,
resp.status_code,
(resp.headers.get("Content-Type") or resp.headers.get("content-type") or ""),
(len(resp.content or b"") if resp is not None else None),
location or None,
)
data: Any = {}
if resp.content:
try:
data = resp.json()
except Exception:
# Some tenants return an empty body or a non-JSON body on successful POST.
data = {}
if self._debug_enabled():
# Log a short preview of the raw body to understand tenant behaviour.
try:
body_preview = (resp.text or "")[:600]
except Exception:
body_preview = ""
logger.info(
"[autotask][%s] POST /Tickets non-JSON body preview=%s",
corr_id,
body_preview,
)
if self._debug_enabled():
logger.info(
"[autotask][%s] POST /Tickets parsed_json_type=%s json_preview=%s",
corr_id,
type(data).__name__,
self._safe_json_preview(data, max_chars=1200),
)
ticket_id: Optional[int] = None
# Autotask may return a lightweight create result like {"itemId": 12345}.
if isinstance(data, dict):
for key in ("itemId", "itemID", "id", "ticketId", "ticketID"):
if key in data and str(data.get(key) or "").isdigit():
ticket_id = int(data[key])
break
# Some variants wrap the created entity.
if ticket_id is None and "item" in data and isinstance(data.get("item"), dict):
item = data.get("item")
if "id" in item and str(item.get("id") or "").isdigit():
ticket_id = int(item["id"])
else:
return item
if ticket_id is None and "items" in data and isinstance(data.get("items"), list) and data.get("items"):
first = data.get("items")[0]
if isinstance(first, dict):
if "id" in first and str(first.get("id") or "").isdigit():
ticket_id = int(first["id"])
else:
return first
# Location header often contains the created entity URL.
if ticket_id is None:
location = (resp.headers.get("Location") or resp.headers.get("location") or "").strip()
if location:
try:
last = location.rstrip("/").split("/")[-1]
if last.isdigit():
ticket_id = int(last)
except Exception:
ticket_id = None
if self._debug_enabled():
logger.info(
"[autotask][%s] POST /Tickets extracted_ticket_id=%s",
corr_id,
ticket_id,
)
# If we have an ID, fetch the full ticket object so callers can reliably access ticketNumber etc.
if ticket_id is not None:
return self.get_ticket(ticket_id)
# Deterministic fallback: query by tracking identifier (+ company) if present.
tracking_identifier = (
payload.get("trackingIdentifier")
or payload.get("TrackingIdentifier")
or ""
)
company_id: Optional[int] = None
for ck in ("companyID", "companyId", "CompanyID"):
if str(payload.get(ck) or "").isdigit():
company_id = int(payload[ck])
break
if self._debug_enabled():
logger.info(
"[autotask][%s] fallback lookup by TrackingIdentifier=%s companyID=%s",
corr_id,
str(tracking_identifier),
company_id,
)
looked_up_id = self._lookup_created_ticket_id(
str(tracking_identifier),
company_id=company_id,
corr_id=corr_id,
)
if looked_up_id is not None:
return self.get_ticket(looked_up_id)
# Last-resort fallback: normalize first item if possible.
items = self._as_items_list(data)
if items:
return items[0]
raise AutotaskError(
"Autotask did not return a ticket id. "
"Ticket creation may still have succeeded. "
f"(HTTP {resp.status_code}, Correlation={corr_id})."
)

View File

@ -16,6 +16,7 @@ from .parsers import parse_mail_message
from .parsers.veeam import extract_vspc_active_alarms_companies from .parsers.veeam import extract_vspc_active_alarms_companies
from .email_utils import normalize_from_address, extract_best_html_from_eml, is_effectively_blank_html from .email_utils import normalize_from_address, extract_best_html_from_eml, is_effectively_blank_html
from .job_matching import find_matching_job from .job_matching import find_matching_job
from .ticketing_utils import link_open_internal_tickets_to_run
GRAPH_TOKEN_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" GRAPH_TOKEN_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
@ -334,6 +335,12 @@ def _store_messages(settings: SystemSettings, messages):
db.session.add(run) db.session.add(run)
db.session.flush() db.session.flush()
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
auto_approved_runs.append((job.customer_id, job.id, run.id, mail.id)) auto_approved_runs.append((job.customer_id, job.id, run.id, mail.id))
created_any = True created_any = True
@ -384,6 +391,12 @@ def _store_messages(settings: SystemSettings, messages):
db.session.add(run) db.session.add(run)
db.session.flush() # ensure run.id is available db.session.flush() # ensure run.id is available
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
# Update mail message to reflect approval # Update mail message to reflect approval
mail.job_id = job.id mail.job_id = job.id
if hasattr(mail, "approved"): if hasattr(mail, "approved"):

View File

@ -1,11 +1,48 @@
from .routes_shared import * # noqa: F401,F403 from .routes_shared import * # noqa: F401,F403
# Explicit imports for robustness across mixed deployments.
from datetime import datetime
from ..database import db
from ..models import SystemSettings
def _get_or_create_settings_local():
"""Return SystemSettings, creating a default row if missing.
This module should not depend on star-imported helpers for settings.
Mixed deployments (partial container updates) can otherwise raise a
NameError on /customers when the shared helper is not present.
"""
settings = SystemSettings.query.first()
if settings is None:
settings = SystemSettings(
auto_import_enabled=False,
auto_import_interval_minutes=15,
auto_import_max_items=50,
manual_import_batch_size=50,
auto_import_cutoff_date=datetime.utcnow().date(),
ingest_eml_retention_days=7,
)
db.session.add(settings)
db.session.commit()
return settings
@main_bp.route("/customers") @main_bp.route("/customers")
@login_required @login_required
@roles_required("admin", "operator", "viewer") @roles_required("admin", "operator", "viewer")
def customers(): def customers():
items = Customer.query.order_by(Customer.name.asc()).all() items = Customer.query.order_by(Customer.name.asc()).all()
settings = _get_or_create_settings_local()
autotask_enabled = bool(getattr(settings, "autotask_enabled", False))
autotask_configured = bool(
(getattr(settings, "autotask_api_username", None))
and (getattr(settings, "autotask_api_password", None))
and (getattr(settings, "autotask_tracking_identifier", None))
)
rows = [] rows = []
for c in items: for c in items:
# Count jobs linked to this customer # Count jobs linked to this customer
@ -19,6 +56,14 @@ def customers():
"name": c.name, "name": c.name,
"active": bool(c.active), "active": bool(c.active),
"job_count": job_count, "job_count": job_count,
"autotask_company_id": getattr(c, "autotask_company_id", None),
"autotask_company_name": getattr(c, "autotask_company_name", None),
"autotask_mapping_status": getattr(c, "autotask_mapping_status", None),
"autotask_last_sync_at": (
getattr(c, "autotask_last_sync_at", None).isoformat(timespec="seconds")
if getattr(c, "autotask_last_sync_at", None)
else None
),
} }
) )
@ -28,9 +73,259 @@ def customers():
"main/customers.html", "main/customers.html",
customers=rows, customers=rows,
can_manage=can_manage, can_manage=can_manage,
autotask_enabled=autotask_enabled,
autotask_configured=autotask_configured,
) )
def _get_autotask_client_or_raise():
"""Build an AutotaskClient from settings or raise a user-safe exception."""
settings = _get_or_create_settings_local()
if not bool(getattr(settings, "autotask_enabled", False)):
raise RuntimeError("Autotask integration is disabled.")
if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier:
raise RuntimeError("Autotask settings incomplete.")
from ..integrations.autotask.client import AutotaskClient
return AutotaskClient(
username=settings.autotask_api_username,
password=settings.autotask_api_password,
api_integration_code=settings.autotask_tracking_identifier,
environment=(settings.autotask_environment or "production"),
)
@main_bp.get("/api/autotask/companies/search")
@login_required
@roles_required("admin", "operator")
def api_autotask_companies_search():
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"status": "ok", "items": []})
try:
client = _get_autotask_client_or_raise()
items = client.search_companies(q, limit=25)
return jsonify({"status": "ok", "items": items})
except Exception as exc:
return jsonify({"status": "error", "message": str(exc) or "Search failed."}), 400
def _normalize_company_name(company: dict) -> str:
# Autotask REST payload shapes vary between tenants/endpoints.
# - Some single-entity GETs return {"item": {...}}
# - Some may return {"items": [{...}]}
if isinstance(company, dict):
item = company.get("item")
if isinstance(item, dict):
company = item
else:
items = company.get("items")
if isinstance(items, list) and items and isinstance(items[0], dict):
company = items[0]
return str(
(company or {}).get("companyName")
or (company or {}).get("CompanyName")
or (company or {}).get("name")
or (company or {}).get("Name")
or ""
).strip()
@main_bp.get("/api/customers/<int:customer_id>/autotask-mapping")
@login_required
@roles_required("admin", "operator", "viewer")
def api_customer_autotask_mapping_get(customer_id: int):
c = Customer.query.get_or_404(customer_id)
return jsonify(
{
"status": "ok",
"customer": {
"id": c.id,
"autotask_company_id": getattr(c, "autotask_company_id", None),
"autotask_company_name": getattr(c, "autotask_company_name", None),
"autotask_mapping_status": getattr(c, "autotask_mapping_status", None),
"autotask_last_sync_at": (
getattr(c, "autotask_last_sync_at", None).isoformat(timespec="seconds")
if getattr(c, "autotask_last_sync_at", None)
else None
),
},
}
)
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping")
@login_required
@roles_required("admin", "operator")
def api_customer_autotask_mapping_set(customer_id: int):
c = Customer.query.get_or_404(customer_id)
payload = request.get_json(silent=True) or {}
company_id = payload.get("company_id")
try:
company_id_int = int(company_id)
except Exception:
return jsonify({"status": "error", "message": "Invalid company_id."}), 400
try:
client = _get_autotask_client_or_raise()
company = client.get_company(company_id_int)
name = _normalize_company_name(company)
c.autotask_company_id = company_id_int
c.autotask_company_name = name
c.autotask_mapping_status = "ok"
c.autotask_last_sync_at = datetime.utcnow()
db.session.commit()
return jsonify({"status": "ok"})
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to set mapping."}), 400
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping/clear")
@login_required
@roles_required("admin", "operator")
def api_customer_autotask_mapping_clear(customer_id: int):
c = Customer.query.get_or_404(customer_id)
try:
c.autotask_company_id = None
c.autotask_company_name = None
c.autotask_mapping_status = None
c.autotask_last_sync_at = datetime.utcnow()
db.session.commit()
return jsonify({"status": "ok"})
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to clear mapping."}), 400
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping/refresh")
@login_required
@roles_required("admin", "operator")
def api_customer_autotask_mapping_refresh(customer_id: int):
from ..integrations.autotask.client import AutotaskError
c = Customer.query.get_or_404(customer_id)
company_id = getattr(c, "autotask_company_id", None)
if not company_id:
return jsonify({"status": "ok", "mapping_status": None})
try:
client = _get_autotask_client_or_raise()
company = client.get_company(int(company_id))
name = _normalize_company_name(company)
prev = (getattr(c, "autotask_company_name", None) or "").strip()
if prev and name and prev != name:
c.autotask_company_name = name
c.autotask_mapping_status = "renamed"
else:
c.autotask_company_name = name
c.autotask_mapping_status = "ok"
c.autotask_last_sync_at = datetime.utcnow()
db.session.commit()
return jsonify({"status": "ok", "mapping_status": c.autotask_mapping_status, "company_name": c.autotask_company_name})
except AutotaskError as exc:
try:
code = getattr(exc, "status_code", None)
except Exception:
code = None
# 404 -> deleted/missing company in Autotask
if code == 404:
try:
c.autotask_mapping_status = "invalid"
c.autotask_last_sync_at = datetime.utcnow()
db.session.commit()
except Exception:
db.session.rollback()
return jsonify({"status": "ok", "mapping_status": "invalid"})
# Other errors: keep mapping but mark as missing (temporary/unreachable)
try:
c.autotask_mapping_status = "missing"
c.autotask_last_sync_at = datetime.utcnow()
db.session.commit()
except Exception:
db.session.rollback()
return jsonify({"status": "ok", "mapping_status": "missing", "message": str(exc)})
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Refresh failed."}), 400
@main_bp.post("/api/customers/autotask-mapping/refresh-all")
@login_required
@roles_required("admin", "operator")
def api_customers_autotask_mapping_refresh_all():
"""Refresh mapping status for all customers that have an Autotask company ID."""
from ..integrations.autotask.client import AutotaskError
customers = Customer.query.filter(Customer.autotask_company_id.isnot(None)).all()
if not customers:
return jsonify({"status": "ok", "refreshed": 0, "counts": {"ok": 0, "renamed": 0, "missing": 0, "invalid": 0}})
try:
client = _get_autotask_client_or_raise()
except Exception as exc:
return jsonify({"status": "error", "message": str(exc) or "Autotask is not configured."}), 400
counts = {"ok": 0, "renamed": 0, "missing": 0, "invalid": 0}
refreshed = 0
for c in customers:
company_id = getattr(c, "autotask_company_id", None)
if not company_id:
continue
try:
company = client.get_company(int(company_id))
name = _normalize_company_name(company)
prev = (getattr(c, "autotask_company_name", None) or "").strip()
if prev and name and prev != name:
c.autotask_company_name = name
c.autotask_mapping_status = "renamed"
counts["renamed"] += 1
else:
c.autotask_company_name = name
c.autotask_mapping_status = "ok"
counts["ok"] += 1
c.autotask_last_sync_at = datetime.utcnow()
refreshed += 1
except AutotaskError as exc:
try:
code = getattr(exc, "status_code", None)
except Exception:
code = None
if code == 404:
c.autotask_mapping_status = "invalid"
counts["invalid"] += 1
else:
c.autotask_mapping_status = "missing"
counts["missing"] += 1
c.autotask_last_sync_at = datetime.utcnow()
refreshed += 1
except Exception:
c.autotask_mapping_status = "missing"
c.autotask_last_sync_at = datetime.utcnow()
counts["missing"] += 1
refreshed += 1
try:
db.session.commit()
return jsonify({"status": "ok", "refreshed": refreshed, "counts": counts})
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to refresh all mappings."}), 400
@main_bp.route("/customers/create", methods=["POST"]) @main_bp.route("/customers/create", methods=["POST"])
@login_required @login_required
@roles_required("admin", "operator") @roles_required("admin", "operator")

View File

@ -4,6 +4,7 @@ from .routes_shared import _format_datetime, _log_admin_event, _send_mail_messag
from ..email_utils import extract_best_html_from_eml, is_effectively_blank_html from ..email_utils import extract_best_html_from_eml, is_effectively_blank_html
from ..parsers.veeam import extract_vspc_active_alarms_companies from ..parsers.veeam import extract_vspc_active_alarms_companies
from ..models import MailObject from ..models import MailObject
from ..ticketing_utils import link_open_internal_tickets_to_run
import time import time
import re import re
@ -295,6 +296,13 @@ def inbox_message_approve(message_id: int):
run.storage_free_percent = msg.storage_free_percent run.storage_free_percent = msg.storage_free_percent
db.session.add(run) db.session.add(run)
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
db.session.flush() # ensure run.id is available
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
# Update mail message to reflect approval # Update mail message to reflect approval
msg.job_id = job.id msg.job_id = job.id
if hasattr(msg, "approved"): if hasattr(msg, "approved"):
@ -538,6 +546,12 @@ def inbox_message_approve_vspc_companies(message_id: int):
db.session.add(run) db.session.add(run)
db.session.flush() db.session.flush()
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
created_runs.append(run) created_runs.append(run)
# Persist objects for reporting (idempotent upsert; safe to repeat). # Persist objects for reporting (idempotent upsert; safe to repeat).
@ -685,6 +699,12 @@ def inbox_message_approve_vspc_companies(message_id: int):
db.session.add(run2) db.session.add(run2)
db.session.flush() db.session.flush()
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run2, job=job2)
except Exception:
pass
# Persist objects per company # Persist objects per company
try: try:
persist_objects_for_approved_run_filtered( persist_objects_for_approved_run_filtered(
@ -1050,6 +1070,12 @@ def inbox_reparse_all():
db.session.add(run) db.session.add(run)
db.session.flush() db.session.flush()
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id)) auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
created_any = True created_any = True
@ -1110,6 +1136,12 @@ def inbox_reparse_all():
db.session.add(run) db.session.add(run)
db.session.flush() # ensure run.id is available db.session.flush() # ensure run.id is available
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id)) auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
msg.job_id = job.id msg.job_id = job.id
@ -1209,6 +1241,12 @@ def inbox_reparse_all():
db.session.add(run) db.session.add(run)
db.session.flush() db.session.flush()
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id)) auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
msg.job_id = job.id msg.job_id = job.id

View File

@ -4,7 +4,8 @@ import calendar
from datetime import date, datetime, time, timedelta, timezone from datetime import date, datetime, time, timedelta, timezone
from flask import jsonify, render_template, request from flask import jsonify, render_template, request, url_for
from urllib.parse import urljoin
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import and_, or_, func, text from sqlalchemy import and_, or_, func, text
@ -31,10 +32,119 @@ from ..models import (
JobRunReviewEvent, JobRunReviewEvent,
MailMessage, MailMessage,
MailObject, MailObject,
Ticket,
TicketJobRun,
TicketScope,
Override, Override,
User, User,
) )
from ..ticketing_utils import (
ensure_internal_ticket_for_job,
ensure_ticket_jobrun_links,
link_open_internal_tickets_to_run,
)
def _build_autotask_client_from_settings():
"""Build an AutotaskClient from stored settings or raise a user-safe exception."""
settings = _get_or_create_settings()
if not getattr(settings, "autotask_enabled", False):
raise RuntimeError("Autotask integration is disabled.")
required = [
getattr(settings, "autotask_environment", None),
getattr(settings, "autotask_api_username", None),
getattr(settings, "autotask_api_password", None),
getattr(settings, "autotask_tracking_identifier", None),
]
if any(not (x and str(x).strip()) for x in required):
raise RuntimeError("Autotask settings incomplete.")
from ..integrations.autotask.client import AutotaskClient
return AutotaskClient(
username=settings.autotask_api_username,
password=settings.autotask_api_password,
api_integration_code=settings.autotask_tracking_identifier,
environment=settings.autotask_environment,
)
def _determine_autotask_severity(status_text: str | None) -> str:
s = (status_text or "").strip().lower()
if "warning" in s:
return "warning"
if "error" in s or "fail" in s:
return "error"
if "missed" in s:
return "error"
return "warning"
def _compose_autotask_ticket_description(
*,
settings,
job: Job,
run: JobRun,
status_display: str,
overall_message: str,
objects_payload: list[dict[str, str]],
) -> str:
tz_name = _get_ui_timezone_name() or "Europe/Amsterdam"
run_dt = run.run_at
run_at_str = _format_datetime(run_dt) if run_dt else "-"
base_url = (getattr(settings, "autotask_base_url", None) or "").strip()
job_rel = url_for("main.job_detail", job_id=job.id)
# Link to Job Details with a hint for the specific run.
job_link = urljoin(base_url.rstrip("/") + "/", job_rel.lstrip("/"))
if run.id:
job_link = f"{job_link}?run_id={int(run.id)}"
lines: list[str] = []
lines.append(f"Customer: {job.customer.name if job.customer else ''}")
lines.append(f"Job: {job.job_name or ''}")
lines.append(f"Backup: {job.backup_software or ''} / {job.backup_type or ''}")
lines.append(f"Run at ({tz_name}): {run_at_str}")
lines.append(f"Status: {status_display or ''}")
lines.append("")
overall_message = (overall_message or "").strip()
if overall_message:
lines.append("Summary:")
lines.append(overall_message)
lines.append("")
lines.append("Multiple objects reported messages. See Backupchecks for full details.")
else:
# Fallback to object-level messages with a hard limit.
limit = 10
shown = 0
total = 0
for o in objects_payload or []:
name = (o.get("name") or "").strip()
err = (o.get("error_message") or "").strip()
st = (o.get("status") or "").strip()
if not name:
continue
if not err and not st:
continue
total += 1
if shown >= limit:
continue
msg = err or st
lines.append(f"- {name}: {msg}")
shown += 1
if total == 0:
lines.append("No detailed object messages available. See Backupchecks for full details.")
elif total > shown:
lines.append(f"And {int(total - shown)} additional objects reported similar messages.")
lines.append("")
lines.append(f"Backupchecks details: {job_link}")
return "\n".join(lines).strip() + "\n"
# Grace window for matching real runs to an expected schedule slot. # Grace window for matching real runs to an expected schedule slot.
# A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot. # A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot.
MISSED_GRACE_WINDOW = timedelta(hours=1) MISSED_GRACE_WINDOW = timedelta(hours=1)
@ -206,6 +316,11 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date)
mail_message_id=None, mail_message_id=None,
) )
db.session.add(miss) db.session.add(miss)
try:
db.session.flush() # ensure miss.id is available
link_open_internal_tickets_to_run(run=miss, job=job)
except Exception:
pass
inserted += 1 inserted += 1
d = d + timedelta(days=1) d = d + timedelta(days=1)
@ -287,6 +402,11 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date)
mail_message_id=None, mail_message_id=None,
) )
db.session.add(miss) db.session.add(miss)
try:
db.session.flush() # ensure miss.id is available
link_open_internal_tickets_to_run(run=miss, job=job)
except Exception:
pass
inserted += 1 inserted += 1
# Next month # Next month
@ -753,6 +873,8 @@ def run_checks_details():
"mail": mail_meta, "mail": mail_meta,
"body_html": body_html, "body_html": body_html,
"objects": objects_payload, "objects": objects_payload,
"autotask_ticket_id": getattr(run, "autotask_ticket_id", None),
"autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "",
} }
) )
@ -770,6 +892,468 @@ def run_checks_details():
return jsonify({"status": "ok", "job": job_payload, "runs": runs_payload}) return jsonify({"status": "ok", "job": job_payload, "runs": runs_payload})
@main_bp.get("/api/run-checks/autotask-ticket-poll")
@login_required
@roles_required("admin", "operator")
def api_run_checks_autotask_ticket_poll():
"""Poll Autotask ticket state for Run Checks.
Notes:
- This endpoint does not mutate Autotask.
- As part of the legacy ticket workflow restoration, it may backfill
missing local metadata (ticket numbers) and internal Ticket/TicketJobRun
relations when those are absent.
"""
include_reviewed = False
if get_active_role() == "admin":
include_reviewed = request.args.get("include_reviewed", "0") in ("1", "true", "yes", "on")
# Only consider recently relevant runs to keep the payload small.
# We intentionally avoid unbounded history polling.
days = 60
try:
days = int(request.args.get("days", "60"))
except Exception:
days = 60
if days < 1:
days = 1
if days > 180:
days = 180
now_utc = datetime.utcnow().replace(tzinfo=None)
window_start = now_utc - timedelta(days=days)
q = JobRun.query.filter(JobRun.autotask_ticket_id.isnot(None))
if not include_reviewed:
q = q.filter(JobRun.reviewed_at.is_(None))
# Only poll runs in our time window.
q = q.filter(func.coalesce(JobRun.run_at, JobRun.created_at) >= window_start)
runs = (
q.order_by(func.coalesce(JobRun.run_at, JobRun.created_at).desc(), JobRun.id.desc())
.limit(400)
.all()
)
ticket_ids = []
seen = set()
for r in runs:
tid = getattr(r, "autotask_ticket_id", None)
try:
tid_int = int(tid)
except Exception:
continue
if tid_int <= 0 or tid_int in seen:
continue
seen.add(tid_int)
ticket_ids.append(tid_int)
if not ticket_ids:
return jsonify({"status": "ok", "tickets": []})
# If integration is disabled, do not fail the page.
settings = _get_or_create_settings()
if not getattr(settings, "autotask_enabled", False):
return jsonify({"status": "ok", "tickets": [], "autotask_enabled": False})
try:
client = _build_autotask_client_from_settings()
except Exception as exc:
return jsonify({"status": "ok", "tickets": [], "autotask_enabled": True, "message": str(exc)})
corr_id = datetime.utcnow().strftime("rcpoll-%Y%m%d%H%M%S")
# Query tickets in Autotask (best-effort)
tickets = []
try:
tickets = client.query_tickets_by_ids(ticket_ids, corr_id=corr_id)
except Exception:
tickets = []
# Build a minimal payload for UI use.
out = []
for t in tickets or []:
if not isinstance(t, dict):
continue
tid = t.get("id")
try:
tid_int = int(tid)
except Exception:
continue
out.append(
{
"id": tid_int,
"ticketNumber": (t.get("ticketNumber") or t.get("TicketNumber") or "") or "",
"status": t.get("status"),
"statusName": (t.get("statusName") or t.get("StatusName") or "") or "",
"title": (t.get("title") or t.get("Title") or "") or "",
"lastActivityDate": (t.get("lastActivityDate") or t.get("LastActivityDate") or t.get("lastActivity") or "") or "",
}
)
# Backfill local ticket numbers and internal Ticket/TicketJobRun records when missing.
# This is intentionally best-effort and must not break the Run Checks page.
try:
id_to_number = {}
id_to_title = {}
for item in out:
tid = item.get("id")
num = (item.get("ticketNumber") or "").strip()
if tid and num:
id_to_number[int(tid)] = num
id_to_title[int(tid)] = (item.get("title") or "").strip() or None
if id_to_number:
# Update JobRun.autotask_ticket_number if empty, and ensure internal ticket workflow exists.
jobs_seen = set()
for r in runs:
try:
tid = int(getattr(r, "autotask_ticket_id", None) or 0)
except Exception:
tid = 0
if tid <= 0 or tid not in id_to_number:
continue
number = id_to_number.get(tid)
if number and not ((getattr(r, "autotask_ticket_number", None) or "").strip()):
r.autotask_ticket_number = number
db.session.add(r)
# Ensure internal Ticket + scope + links exist (per job)
if r.job_id and int(r.job_id) not in jobs_seen:
jobs_seen.add(int(r.job_id))
job = Job.query.get(r.job_id)
if not job:
continue
ticket = ensure_internal_ticket_for_job(
ticket_code=number,
title=id_to_title.get(tid),
description=f"Autotask ticket {number}",
job=job,
active_from_dt=getattr(r, "run_at", None),
start_dt=datetime.utcnow(),
)
# Link all currently active (unreviewed) runs for this job.
run_ids = [
int(x)
for (x,) in (
JobRun.query.filter(JobRun.job_id == job.id)
.filter(JobRun.reviewed_at.is_(None))
.with_entities(JobRun.id)
.all()
)
if x is not None
]
ensure_ticket_jobrun_links(ticket_id=ticket.id, run_ids=run_ids, link_source="autotask")
db.session.commit()
except Exception:
try:
db.session.rollback()
except Exception:
pass
return jsonify({"status": "ok", "tickets": out, "autotask_enabled": True})
@main_bp.post("/api/run-checks/autotask-ticket")
@login_required
@roles_required("admin", "operator")
def api_run_checks_create_autotask_ticket():
"""Create an Autotask ticket for a specific run.
Enforces: exactly one ticket per run.
"""
data = request.get_json(silent=True) or {}
try:
run_id = int(data.get("run_id") or 0)
except Exception:
run_id = 0
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
# Idempotent behavior:
# If the run already has an Autotask ticket linked, we still continue so we can:
# - propagate the linkage to all active (non-reviewed) runs of the same job
# - synchronize the internal Ticket + TicketJobRun records (used by Tickets/Remarks + Job Details)
already_exists = False
existing_ticket_id = getattr(run, "autotask_ticket_id", None)
existing_ticket_number = (getattr(run, "autotask_ticket_number", None) or "").strip() or None
if existing_ticket_id:
already_exists = True
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()
base_url = (getattr(settings, "autotask_base_url", None) or "").strip()
if not base_url:
return jsonify({"status": "error", "message": "Autotask Base URL is not configured."}), 400
# Required ticket defaults
if not getattr(settings, "autotask_default_queue_id", None):
return jsonify({"status": "error", "message": "Autotask default queue is not configured."}), 400
if not getattr(settings, "autotask_default_ticket_source_id", None):
return jsonify({"status": "error", "message": "Autotask default ticket source is not configured."}), 400
if not getattr(settings, "autotask_default_ticket_status", None):
return jsonify({"status": "error", "message": "Autotask default ticket status is not configured."}), 400
# Determine display status (including overrides) for consistent subject/priority mapping.
status_display = run.status or "-"
try:
status_display, _, _, _ov_id, _ov_reason = _apply_overrides_to_run(job, run)
except Exception:
status_display = run.status or "-"
severity = _determine_autotask_severity(status_display)
priority_id = None
if severity == "warning":
priority_id = getattr(settings, "autotask_priority_warning", None)
else:
priority_id = getattr(settings, "autotask_priority_error", None)
# Load mail + objects for ticket composition.
msg = MailMessage.query.get(run.mail_message_id) if run.mail_message_id else None
overall_message = (getattr(msg, "overall_message", None) or "") if msg else ""
objects_payload: list[dict[str, str]] = []
try:
objs = run.objects.order_by(JobObject.object_name.asc()).all()
except Exception:
objs = list(run.objects or [])
for o in objs or []:
objects_payload.append(
{
"name": getattr(o, "object_name", "") or "",
"type": getattr(o, "object_type", "") or "",
"status": getattr(o, "status", "") or "",
"error_message": getattr(o, "error_message", "") or "",
}
)
if (not objects_payload) and msg:
try:
mos = MailObject.query.filter_by(mail_message_id=msg.id).order_by(MailObject.object_name.asc()).all()
except Exception:
mos = []
for mo in mos or []:
objects_payload.append(
{
"name": getattr(mo, "object_name", "") or "",
"type": getattr(mo, "object_type", "") or "",
"status": getattr(mo, "status", "") or "",
"error_message": getattr(mo, "error_message", "") or "",
}
)
subject = f"[Backupchecks] {customer.name} - {job.job_name or ''} - {status_display}"
description = _compose_autotask_ticket_description(
settings=settings,
job=job,
run=run,
status_display=status_display,
overall_message=overall_message,
objects_payload=objects_payload,
)
payload = {
"companyID": int(customer.autotask_company_id),
"title": subject,
"description": description,
"queueID": int(settings.autotask_default_queue_id),
"source": int(settings.autotask_default_ticket_source_id),
"status": int(settings.autotask_default_ticket_status),
}
if priority_id:
payload["priority"] = int(priority_id)
try:
client = _build_autotask_client_from_settings()
except Exception as exc:
return jsonify({"status": "error", "message": f"Autotask client initialization failed: {exc}"}), 400
ticket_id = None
ticket_number = None
if already_exists:
try:
ticket_id = int(existing_ticket_id)
except Exception:
ticket_id = None
ticket_number = existing_ticket_number
else:
try:
created = client.create_ticket(payload)
except Exception as exc:
return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400
if isinstance(created, dict):
ticket_id = created.get("id") or created.get("itemId") or created.get("ticketId")
try:
# Some wrappers return {"item": {"id": ...}}
if not ticket_id and isinstance(created.get("item"), dict):
ticket_id = created.get("item", {}).get("id")
except Exception:
pass
if not ticket_id:
return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400
# Autotask typically does not return the ticket number on create.
# Also, existing linkages may have ticket_id but no ticket_number yet.
# Always fetch the ticket if we don't have the number so we can persist it for UI and internal linking.
if ticket_id and not ticket_number:
try:
fetched = client.get_ticket(int(ticket_id))
if isinstance(fetched, dict) and isinstance(fetched.get("item"), dict):
fetched = fetched.get("item")
if isinstance(fetched, dict):
ticket_number = fetched.get("ticketNumber") or fetched.get("number") or fetched.get("ticket_number")
except Exception:
ticket_number = ticket_number or None
# Link the created Autotask ticket to all relevant open runs of the same job.
# This matches the manual ticket workflow where one ticket remains visible across runs
# until it is explicitly resolved.
now = datetime.utcnow()
# Collect the open run IDs first (stable list), then apply updates and internal linking.
linked_run_ids: list[int] = []
try:
rows = (
JobRun.query.filter(JobRun.job_id == run.job_id)
.filter(JobRun.reviewed_at.is_(None))
.with_entities(JobRun.id)
.order_by(JobRun.id.asc())
.all()
)
linked_run_ids = [int(rid) for (rid,) in rows if rid is not None]
except Exception:
linked_run_ids = []
# Safety: always include the explicitly selected run.
try:
if run.id and int(run.id) not in linked_run_ids:
linked_run_ids.append(int(run.id))
except Exception:
pass
# Load run objects for the IDs we determined.
open_runs = []
if linked_run_ids:
open_runs = JobRun.query.filter(JobRun.id.in_(linked_run_ids)).all()
else:
open_runs = [run]
try:
if run.id:
linked_run_ids = [int(run.id)]
except Exception:
linked_run_ids = []
for r in open_runs or []:
# Do not overwrite an existing (different) ticket linkage.
existing_id = getattr(r, "autotask_ticket_id", None)
if existing_id:
try:
if int(existing_id) != int(ticket_id):
continue
except Exception:
continue
try:
r.autotask_ticket_id = int(ticket_id)
except Exception:
r.autotask_ticket_id = None
r.autotask_ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None
r.autotask_ticket_created_at = now
r.autotask_ticket_created_by_user_id = current_user.id
# Also store an internal Ticket record and link it to all relevant active runs.
# This keeps Tickets/Remarks, Job Details, and Run Checks indicators consistent with the existing manual workflow,
# and remains functional even if PSA integration is disabled later.
internal_ticket = None
if ticket_number:
ticket_code = (str(ticket_number) or "").strip().upper()
internal_ticket = Ticket.query.filter_by(ticket_code=ticket_code).first()
if not internal_ticket:
internal_ticket = Ticket(
ticket_code=ticket_code,
title=subject,
description=description,
active_from_date=_to_amsterdam_date(run.run_at) or _to_amsterdam_date(now) or now.date(),
start_date=now,
resolved_at=None,
)
db.session.add(internal_ticket)
db.session.flush()
# Ensure a job scope exists (used by popups / job details / tickets page)
scope = None
if job and job.id and internal_ticket and internal_ticket.id:
scope = TicketScope.query.filter_by(ticket_id=internal_ticket.id, scope_type="job", job_id=job.id).first()
if not scope and internal_ticket and internal_ticket.id:
scope = TicketScope(
ticket_id=internal_ticket.id,
scope_type="job",
customer_id=job.customer_id if job else None,
backup_software=job.backup_software if job else None,
backup_type=job.backup_type if job else None,
job_id=job.id if job else None,
job_name_match=job.job_name if job else None,
job_name_match_mode="exact",
resolved_at=None,
)
db.session.add(scope)
elif scope:
scope.resolved_at = None
# Link ticket to all relevant active job runs (idempotent)
for rid in linked_run_ids or []:
if not TicketJobRun.query.filter_by(ticket_id=internal_ticket.id, job_run_id=rid).first():
db.session.add(TicketJobRun(ticket_id=internal_ticket.id, job_run_id=rid, link_source="autotask"))
try:
for r in open_runs or []:
db.session.add(r)
db.session.commit()
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": f"Failed to store ticket reference: {exc}"}), 500
return jsonify(
{
"status": "ok",
"ticket_id": int(run.autotask_ticket_id) if run.autotask_ticket_id else None,
"ticket_number": run.autotask_ticket_number or "",
"already_exists": bool(already_exists),
"linked_run_ids": linked_run_ids or [],
}
)
@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")

View File

@ -1,5 +1,7 @@
from .routes_shared import * # noqa: F401,F403 from .routes_shared import * # noqa: F401,F403
from .routes_shared import _get_database_size_bytes, _get_or_create_settings, _format_bytes, _get_free_disk_bytes, _log_admin_event from .routes_shared import _get_database_size_bytes, _get_or_create_settings, _format_bytes, _get_free_disk_bytes, _log_admin_event
import json
from datetime import datetime
@main_bp.route("/settings/jobs/delete-all", methods=["POST"]) @main_bp.route("/settings/jobs/delete-all", methods=["POST"])
@login_required @login_required
@ -430,6 +432,61 @@ def settings():
if "ui_timezone" in request.form: if "ui_timezone" in request.form:
settings.ui_timezone = (request.form.get("ui_timezone") or "").strip() or "Europe/Amsterdam" settings.ui_timezone = (request.form.get("ui_timezone") or "").strip() or "Europe/Amsterdam"
# Autotask integration
if "autotask_enabled" in request.form:
settings.autotask_enabled = bool(request.form.get("autotask_enabled"))
if "autotask_environment" in request.form:
env_val = (request.form.get("autotask_environment") or "").strip().lower()
if env_val in ("sandbox", "production"):
settings.autotask_environment = env_val
else:
settings.autotask_environment = None
if "autotask_api_username" in request.form:
settings.autotask_api_username = (request.form.get("autotask_api_username") or "").strip() or None
if "autotask_api_password" in request.form:
pw = (request.form.get("autotask_api_password") or "").strip()
if pw:
settings.autotask_api_password = pw
if "autotask_tracking_identifier" in request.form:
settings.autotask_tracking_identifier = (request.form.get("autotask_tracking_identifier") or "").strip() or None
if "autotask_base_url" in request.form:
settings.autotask_base_url = (request.form.get("autotask_base_url") or "").strip() or None
if "autotask_default_queue_id" in request.form:
try:
settings.autotask_default_queue_id = int(request.form.get("autotask_default_queue_id") or 0) or None
except (ValueError, TypeError):
pass
if "autotask_default_ticket_source_id" in request.form:
try:
settings.autotask_default_ticket_source_id = int(request.form.get("autotask_default_ticket_source_id") or 0) or None
except (ValueError, TypeError):
pass
if "autotask_default_ticket_status" in request.form:
try:
settings.autotask_default_ticket_status = int(request.form.get("autotask_default_ticket_status") or 0) or None
except (ValueError, TypeError):
pass
if "autotask_priority_warning" in request.form:
try:
settings.autotask_priority_warning = int(request.form.get("autotask_priority_warning") or 0) or None
except (ValueError, TypeError):
pass
if "autotask_priority_error" in request.form:
try:
settings.autotask_priority_error = int(request.form.get("autotask_priority_error") or 0) or None
except (ValueError, TypeError):
pass
# Daily Jobs # Daily Jobs
if "daily_jobs_start_date" in request.form: if "daily_jobs_start_date" in request.form:
daily_jobs_start_date_str = (request.form.get("daily_jobs_start_date") or "").strip() daily_jobs_start_date_str = (request.form.get("daily_jobs_start_date") or "").strip()
@ -537,6 +594,7 @@ def settings():
free_disk_warning = free_disk_bytes < two_gb free_disk_warning = free_disk_bytes < two_gb
has_client_secret = bool(settings.graph_client_secret) has_client_secret = bool(settings.graph_client_secret)
has_autotask_password = bool(getattr(settings, "autotask_api_password", None))
# Common UI timezones (IANA names) # Common UI timezones (IANA names)
tz_options = [ tz_options = [
@ -595,6 +653,37 @@ def settings():
except Exception: except Exception:
admin_users_count = 0 admin_users_count = 0
# Autotask cached reference data for dropdowns
autotask_queues = []
autotask_ticket_sources = []
autotask_priorities = []
autotask_ticket_statuses = []
autotask_last_sync_at = getattr(settings, "autotask_reference_last_sync_at", None)
try:
if getattr(settings, "autotask_cached_queues_json", None):
autotask_queues = json.loads(settings.autotask_cached_queues_json) or []
except Exception:
autotask_queues = []
try:
if getattr(settings, "autotask_cached_ticket_sources_json", None):
autotask_ticket_sources = json.loads(settings.autotask_cached_ticket_sources_json) or []
except Exception:
autotask_ticket_sources = []
try:
if getattr(settings, "autotask_cached_priorities_json", None):
autotask_priorities = json.loads(settings.autotask_cached_priorities_json) or []
except Exception:
autotask_priorities = []
try:
if getattr(settings, "autotask_cached_ticket_statuses_json", None):
autotask_ticket_statuses = json.loads(settings.autotask_cached_ticket_statuses_json) or []
except Exception:
autotask_ticket_statuses = []
return render_template( return render_template(
"main/settings.html", "main/settings.html",
settings=settings, settings=settings,
@ -602,10 +691,16 @@ def settings():
free_disk_human=free_disk_human, free_disk_human=free_disk_human,
free_disk_warning=free_disk_warning, free_disk_warning=free_disk_warning,
has_client_secret=has_client_secret, has_client_secret=has_client_secret,
has_autotask_password=has_autotask_password,
tz_options=tz_options, tz_options=tz_options,
users=users, users=users,
admin_users_count=admin_users_count, admin_users_count=admin_users_count,
section=section, section=section,
autotask_queues=autotask_queues,
autotask_ticket_sources=autotask_ticket_sources,
autotask_priorities=autotask_priorities,
autotask_ticket_statuses=autotask_ticket_statuses,
autotask_last_sync_at=autotask_last_sync_at,
news_admin_items=news_admin_items, news_admin_items=news_admin_items,
news_admin_stats=news_admin_stats, news_admin_stats=news_admin_stats,
) )
@ -1172,3 +1267,140 @@ def settings_folders():
except Exception: except Exception:
pass pass
return jsonify({"status": "error", "message": str(exc) or "Failed to load folders."}), 500 return jsonify({"status": "error", "message": str(exc) or "Failed to load folders."}), 500
@main_bp.route("/settings/autotask/test-connection", methods=["POST"])
@login_required
@roles_required("admin")
def settings_autotask_test_connection():
settings = _get_or_create_settings()
if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier:
flash("Autotask settings incomplete. Provide username, password and tracking identifier first.", "warning")
return redirect(url_for("main.settings", section="integrations"))
try:
from ..integrations.autotask.client import AutotaskClient
client = AutotaskClient(
username=settings.autotask_api_username,
password=settings.autotask_api_password,
api_integration_code=settings.autotask_tracking_identifier,
environment=(settings.autotask_environment or "production"),
)
zone = client.get_zone_info()
# Lightweight authenticated calls to validate credentials and basic API access
_ = client.get_queues()
_ = client.get_ticket_sources()
flash(f"Autotask connection OK. Zone: {zone.zone_name or 'unknown'}.", "success")
_log_admin_event(
"autotask_test_connection",
"Autotask test connection succeeded.",
details=json.dumps({"zone": zone.zone_name, "api_url": zone.api_url}),
)
except Exception as exc:
flash(f"Autotask connection failed: {exc}", "danger")
_log_admin_event(
"autotask_test_connection_failed",
"Autotask test connection failed.",
details=json.dumps({"error": str(exc)}),
)
return redirect(url_for("main.settings", section="integrations"))
@main_bp.route("/settings/autotask/refresh-reference-data", methods=["POST"])
@login_required
@roles_required("admin")
def settings_autotask_refresh_reference_data():
settings = _get_or_create_settings()
if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier:
flash("Autotask settings incomplete. Provide username, password and tracking identifier first.", "warning")
return redirect(url_for("main.settings", section="integrations"))
try:
from ..integrations.autotask.client import AutotaskClient
client = AutotaskClient(
username=settings.autotask_api_username,
password=settings.autotask_api_password,
api_integration_code=settings.autotask_tracking_identifier,
environment=(settings.autotask_environment or "production"),
)
queues = client.get_queues()
sources = client.get_ticket_sources()
priorities = client.get_ticket_priorities()
statuses = client.get_ticket_statuses()
# Store a minimal subset for dropdowns (id + name/label)
# Note: Some "reference" values are exposed as picklists (value/label)
# instead of entity collections (id/name). We normalize both shapes.
def _norm(items):
out = []
for it in items or []:
if not isinstance(it, dict):
continue
_id = it.get("id")
if _id is None:
_id = it.get("value")
name = (
it.get("name")
or it.get("label")
or it.get("queueName")
or it.get("sourceName")
or it.get("description")
or ""
)
try:
_id_int = int(_id)
except Exception:
continue
out.append({"id": _id_int, "name": str(name)})
# Sort by name for stable dropdowns
out.sort(key=lambda x: (x.get("name") or "").lower())
return out
settings.autotask_cached_queues_json = json.dumps(_norm(queues))
settings.autotask_cached_ticket_sources_json = json.dumps(_norm(sources))
settings.autotask_cached_ticket_statuses_json = json.dumps(_norm(statuses))
# Priorities are returned as picklist values (value/label)
pr_out = []
for it in priorities or []:
if not isinstance(it, dict):
continue
if it.get("isActive") is False:
continue
val = it.get("value")
label = it.get("label") or it.get("name") or ""
try:
val_int = int(val)
except Exception:
continue
pr_out.append({"id": val_int, "name": str(label)})
pr_out.sort(key=lambda x: (x.get("name") or "").lower())
settings.autotask_cached_priorities_json = json.dumps(pr_out)
settings.autotask_reference_last_sync_at = datetime.utcnow()
db.session.commit()
flash(
f"Autotask reference data refreshed. Queues: {len(queues)}. Ticket Sources: {len(sources)}. Ticket Statuses: {len(statuses)}. Priorities: {len(pr_out)}.",
"success",
)
_log_admin_event(
"autotask_refresh_reference_data",
"Autotask reference data refreshed.",
details=json.dumps({"queues": len(queues or []), "ticket_sources": len(sources or []), "ticket_statuses": len(statuses or []), "priorities": len(pr_out)}),
)
except Exception as exc:
flash(f"Failed to refresh Autotask reference data: {exc}", "danger")
_log_admin_event(
"autotask_refresh_reference_data_failed",
"Autotask reference data refresh failed.",
details=json.dumps({"error": str(exc)}),
)
return redirect(url_for("main.settings", section="integrations"))

View File

@ -22,6 +22,27 @@ def _is_column_nullable(table_name: str, column_name: str) -> bool:
return False return False
def _column_exists_on_conn(conn, table_name: str, column_name: str) -> bool:
"""Return True if the given column exists using the provided connection.
This helper is useful inside engine.begin() blocks so we can check
column existence without creating a new inspector/connection.
"""
result = conn.execute(
text(
"""
SELECT 1
FROM information_schema.columns
WHERE table_name = :table
AND column_name = :column
LIMIT 1
"""
),
{"table": table_name, "column": column_name},
)
return result.first() is not None
def migrate_add_username_to_users() -> None: def migrate_add_username_to_users() -> None:
"""Ensure users.username column exists and is NOT NULL and UNIQUE. """Ensure users.username column exists and is NOT NULL and UNIQUE.
@ -127,6 +148,84 @@ def migrate_system_settings_ui_timezone() -> None:
except Exception as exc: except Exception as exc:
print(f"[migrations] Failed to migrate system_settings.ui_timezone: {exc}") print(f"[migrations] Failed to migrate system_settings.ui_timezone: {exc}")
def migrate_system_settings_autotask_integration() -> None:
"""Add Autotask integration columns to system_settings if missing."""
table = "system_settings"
columns = [
("autotask_enabled", "BOOLEAN NOT NULL DEFAULT FALSE"),
("autotask_environment", "VARCHAR(32) NULL"),
("autotask_api_username", "VARCHAR(255) NULL"),
("autotask_api_password", "VARCHAR(255) NULL"),
("autotask_tracking_identifier", "VARCHAR(255) NULL"),
("autotask_base_url", "VARCHAR(512) NULL"),
("autotask_default_queue_id", "INTEGER NULL"),
("autotask_default_ticket_source_id", "INTEGER NULL"),
("autotask_default_ticket_status", "INTEGER NULL"),
("autotask_priority_warning", "INTEGER NULL"),
("autotask_priority_error", "INTEGER NULL"),
("autotask_cached_queues_json", "TEXT NULL"),
("autotask_cached_ticket_sources_json", "TEXT NULL"),
("autotask_cached_priorities_json", "TEXT NULL"),
("autotask_cached_ticket_statuses_json", "TEXT NULL"),
("autotask_reference_last_sync_at", "TIMESTAMP NULL"),
]
try:
engine = db.get_engine()
except Exception as exc:
print(f"[migrations] Could not get engine for system_settings autotask migration: {exc}")
return
try:
with engine.begin() as conn:
for column, ddl in columns:
if _column_exists_on_conn(conn, table, column):
continue
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN {column} {ddl}'))
print("[migrations] migrate_system_settings_autotask_integration completed.")
except Exception as exc:
print(f"[migrations] Failed to migrate system_settings autotask integration columns: {exc}")
def migrate_customers_autotask_company_mapping() -> None:
"""Add Autotask company mapping columns to customers if missing.
Columns:
- autotask_company_id (INTEGER NULL)
- autotask_company_name (VARCHAR(255) NULL)
- autotask_mapping_status (VARCHAR(20) NULL)
- autotask_last_sync_at (TIMESTAMP NULL)
"""
table = "customers"
columns = [
("autotask_company_id", "INTEGER NULL"),
("autotask_company_name", "VARCHAR(255) NULL"),
("autotask_mapping_status", "VARCHAR(20) NULL"),
("autotask_last_sync_at", "TIMESTAMP NULL"),
]
try:
engine = db.get_engine()
except Exception as exc:
print(f"[migrations] Could not get engine for customers autotask mapping migration: {exc}")
return
try:
with engine.begin() as conn:
for column, ddl in columns:
if _column_exists_on_conn(conn, table, column):
continue
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN {column} {ddl}'))
print("[migrations] migrate_customers_autotask_company_mapping completed.")
except Exception as exc:
print(f"[migrations] Failed to migrate customers autotask company mapping columns: {exc}")
def migrate_mail_messages_columns() -> None: def migrate_mail_messages_columns() -> None:
@ -779,6 +878,8 @@ def run_migrations() -> None:
migrate_system_settings_auto_import_cutoff_date() migrate_system_settings_auto_import_cutoff_date()
migrate_system_settings_daily_jobs_start_date() migrate_system_settings_daily_jobs_start_date()
migrate_system_settings_ui_timezone() migrate_system_settings_ui_timezone()
migrate_system_settings_autotask_integration()
migrate_customers_autotask_company_mapping()
migrate_mail_messages_columns() migrate_mail_messages_columns()
migrate_mail_messages_parse_columns() migrate_mail_messages_parse_columns()
migrate_mail_messages_approval_columns() migrate_mail_messages_approval_columns()
@ -797,6 +898,7 @@ def run_migrations() -> None:
migrate_overrides_match_columns() migrate_overrides_match_columns()
migrate_job_runs_review_tracking() migrate_job_runs_review_tracking()
migrate_job_runs_override_metadata() migrate_job_runs_override_metadata()
migrate_job_runs_autotask_ticket_fields()
migrate_jobs_archiving() migrate_jobs_archiving()
migrate_news_tables() migrate_news_tables()
migrate_reporting_tables() migrate_reporting_tables()
@ -804,6 +906,67 @@ def run_migrations() -> None:
print("[migrations] All migrations completed.") print("[migrations] All migrations completed.")
def migrate_job_runs_autotask_ticket_fields() -> None:
"""Add Autotask ticket linkage fields to job_runs if missing.
Columns:
- job_runs.autotask_ticket_id (INTEGER NULL)
- job_runs.autotask_ticket_number (VARCHAR(64) NULL)
- job_runs.autotask_ticket_created_at (TIMESTAMP NULL)
- job_runs.autotask_ticket_created_by_user_id (INTEGER NULL, FK users.id)
"""
table = "job_runs"
try:
engine = db.get_engine()
except Exception as exc:
print(f"[migrations] Could not get engine for job_runs Autotask ticket migration: {exc}")
return
try:
with engine.connect() as conn:
cols = _get_table_columns(conn, table)
if not cols:
return
if "autotask_ticket_id" not in cols:
print("[migrations] Adding job_runs.autotask_ticket_id column...")
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_id INTEGER'))
if "autotask_ticket_number" not in cols:
print("[migrations] Adding job_runs.autotask_ticket_number column...")
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_number VARCHAR(64)'))
if "autotask_ticket_created_at" not in cols:
print("[migrations] Adding job_runs.autotask_ticket_created_at column...")
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_created_at TIMESTAMP'))
if "autotask_ticket_created_by_user_id" not in cols:
print("[migrations] Adding job_runs.autotask_ticket_created_by_user_id column...")
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_created_by_user_id INTEGER'))
try:
conn.execute(
text(
'ALTER TABLE "job_runs" '
'ADD CONSTRAINT job_runs_autotask_ticket_created_by_user_id_fkey '
'FOREIGN KEY (autotask_ticket_created_by_user_id) REFERENCES users(id) '
'ON DELETE SET NULL'
)
)
except Exception as exc:
print(
f"[migrations] Could not add FK job_runs.autotask_ticket_created_by_user_id -> users.id (continuing): {exc}"
)
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_id ON "job_runs" (autotask_ticket_id)'))
except Exception as exc:
print(f"[migrations] job_runs table not found; skipping migrate_job_runs_autotask_ticket_fields: {exc}")
return
print("[migrations] migrate_job_runs_autotask_ticket_fields completed.")
def migrate_jobs_archiving() -> None: def migrate_jobs_archiving() -> None:
"""Add archiving columns to jobs if missing. """Add archiving columns to jobs if missing.

View File

@ -107,6 +107,28 @@ class SystemSettings(db.Model):
# UI display timezone (IANA name). Used for rendering times in the web interface. # UI display timezone (IANA name). Used for rendering times in the web interface.
ui_timezone = db.Column(db.String(64), nullable=False, default="Europe/Amsterdam") ui_timezone = db.Column(db.String(64), nullable=False, default="Europe/Amsterdam")
# Autotask integration settings
autotask_enabled = db.Column(db.Boolean, nullable=False, default=False)
autotask_environment = db.Column(db.String(32), nullable=True) # sandbox | production
autotask_api_username = db.Column(db.String(255), nullable=True)
autotask_api_password = db.Column(db.String(255), nullable=True)
autotask_tracking_identifier = db.Column(db.String(255), nullable=True)
autotask_base_url = db.Column(db.String(512), nullable=True) # Backupchecks base URL for deep links
# Autotask defaults (IDs are leading)
autotask_default_queue_id = db.Column(db.Integer, nullable=True)
autotask_default_ticket_source_id = db.Column(db.Integer, nullable=True)
autotask_default_ticket_status = db.Column(db.Integer, nullable=True)
autotask_priority_warning = db.Column(db.Integer, nullable=True)
autotask_priority_error = db.Column(db.Integer, nullable=True)
# Cached reference data (for dropdowns)
autotask_cached_queues_json = db.Column(db.Text, nullable=True)
autotask_cached_ticket_sources_json = db.Column(db.Text, nullable=True)
autotask_cached_priorities_json = db.Column(db.Text, nullable=True)
autotask_cached_ticket_statuses_json = db.Column(db.Text, nullable=True)
autotask_reference_last_sync_at = db.Column(db.DateTime, nullable=True)
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( updated_at = db.Column(
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
@ -132,6 +154,14 @@ class Customer(db.Model):
name = db.Column(db.String(255), unique=True, nullable=False) name = db.Column(db.String(255), unique=True, nullable=False)
active = db.Column(db.Boolean, nullable=False, default=True) active = db.Column(db.Boolean, nullable=False, default=True)
# Autotask company mapping (Phase 3)
# Company ID is leading; name is cached for UI display.
autotask_company_id = db.Column(db.Integer, nullable=True)
autotask_company_name = db.Column(db.String(255), nullable=True)
# Mapping status: ok | renamed | missing | invalid
autotask_mapping_status = db.Column(db.String(20), nullable=True)
autotask_last_sync_at = db.Column(db.DateTime, nullable=True)
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( updated_at = db.Column(
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
@ -246,6 +276,12 @@ class JobRun(db.Model):
reviewed_at = db.Column(db.DateTime, nullable=True) reviewed_at = db.Column(db.DateTime, nullable=True)
reviewed_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) reviewed_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
# Autotask integration (Phase 4: ticket creation from Run Checks)
autotask_ticket_id = db.Column(db.Integer, nullable=True)
autotask_ticket_number = db.Column(db.String(64), nullable=True)
autotask_ticket_created_at = db.Column(db.DateTime, nullable=True)
autotask_ticket_created_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
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( updated_at = db.Column(
@ -259,6 +295,8 @@ class JobRun(db.Model):
reviewed_by = db.relationship("User", foreign_keys=[reviewed_by_user_id]) reviewed_by = db.relationship("User", foreign_keys=[reviewed_by_user_id])
autotask_ticket_created_by = db.relationship("User", foreign_keys=[autotask_ticket_created_by_user_id])
class JobRunReviewEvent(db.Model): class JobRunReviewEvent(db.Model):
__tablename__ = "job_run_review_events" __tablename__ = "job_run_review_events"

View File

@ -0,0 +1,235 @@
from __future__ import annotations
from datetime import datetime, date, timezone
from typing import Iterable, Optional
from zoneinfo import ZoneInfo
from flask import current_app
from sqlalchemy import text
from .database import db
from .models import Job, JobRun, SystemSettings, Ticket, TicketJobRun, TicketScope
def _get_ui_timezone_name() -> str:
"""Return the configured UI timezone name (IANA), with a safe fallback.
NOTE: This must not import from any routes_* modules to avoid circular imports.
"""
try:
settings = SystemSettings.query.first()
name = (getattr(settings, "ui_timezone", None) or "").strip()
if name:
return name
except Exception:
pass
try:
return (current_app.config.get("TIMEZONE") or "Europe/Amsterdam").strip()
except Exception:
return "Europe/Amsterdam"
def _to_ui_date(dt_utc_naive: datetime | None) -> date | None:
"""Convert a naive UTC datetime to a UI-local date."""
if not dt_utc_naive:
return None
try:
tz = ZoneInfo(_get_ui_timezone_name())
except Exception:
tz = None
if not tz:
return dt_utc_naive.date()
try:
if dt_utc_naive.tzinfo is None:
dt_utc = dt_utc_naive.replace(tzinfo=timezone.utc)
else:
dt_utc = dt_utc_naive.astimezone(timezone.utc)
return dt_utc.astimezone(tz).date()
except Exception:
return dt_utc_naive.date()
def ensure_internal_ticket_for_job(
*,
ticket_code: str,
title: Optional[str],
description: str,
job: Job,
active_from_dt: Optional[datetime],
start_dt: Optional[datetime] = None,
) -> Ticket:
"""Create/reuse an internal Ticket and ensure a job scope exists.
This mirrors the legacy manual ticket workflow but allows arbitrary ticket codes
(e.g. Autotask ticket numbers).
"""
now = datetime.utcnow()
start_dt = start_dt or now
code = (ticket_code or "").strip().upper()
if not code:
raise ValueError("ticket_code is required")
ticket = Ticket.query.filter_by(ticket_code=code).first()
if not ticket:
ticket = Ticket(
ticket_code=code,
title=title,
description=description,
active_from_date=_to_ui_date(active_from_dt) or _to_ui_date(start_dt) or start_dt.date(),
start_date=start_dt,
resolved_at=None,
)
db.session.add(ticket)
db.session.flush()
# Ensure an open job scope exists
scope = TicketScope.query.filter_by(ticket_id=ticket.id, scope_type="job", job_id=job.id).first()
if not scope:
scope = TicketScope(
ticket_id=ticket.id,
scope_type="job",
customer_id=job.customer_id,
backup_software=job.backup_software,
backup_type=job.backup_type,
job_id=job.id,
job_name_match=job.job_name,
job_name_match_mode="exact",
resolved_at=None,
)
db.session.add(scope)
else:
# Re-open and refresh scope metadata (legacy behavior)
scope.resolved_at = None
scope.customer_id = job.customer_id
scope.backup_software = job.backup_software
scope.backup_type = job.backup_type
scope.job_name_match = job.job_name
scope.job_name_match_mode = "exact"
return ticket
def ensure_ticket_jobrun_links(
*,
ticket_id: int,
run_ids: Iterable[int],
link_source: str,
) -> None:
"""Idempotently ensure TicketJobRun links exist for all provided run IDs."""
run_ids_list = [int(x) for x in (run_ids or []) if x is not None]
if not run_ids_list:
return
existing = set()
try:
rows = (
db.session.execute(
text(
"""
SELECT job_run_id
FROM ticket_job_runs
WHERE ticket_id = :ticket_id
AND job_run_id = ANY(:run_ids)
"""
),
{"ticket_id": int(ticket_id), "run_ids": run_ids_list},
)
.fetchall()
)
existing = {int(rid) for (rid,) in rows if rid is not None}
except Exception:
existing = set()
for rid in run_ids_list:
if rid in existing:
continue
db.session.add(TicketJobRun(ticket_id=int(ticket_id), job_run_id=int(rid), link_source=link_source))
def link_open_internal_tickets_to_run(*, run: JobRun, job: Job) -> None:
"""When a new run is created, link any currently open internal tickets for the job.
This restores legacy behavior where a ticket stays visible for new runs until resolved.
Additionally (best-effort), if the job already has Autotask linkage on previous runs,
propagate that to the new run so PSA polling remains consistent.
"""
if not run or not getattr(run, "id", None) or not job or not getattr(job, "id", None):
return
ui_tz = _get_ui_timezone_name()
run_date = _to_ui_date(getattr(run, "run_at", None)) or _to_ui_date(datetime.utcnow())
# Find open tickets scoped to this job for the run date window.
# This matches the logic used by Job Details and Run Checks indicators.
rows = []
try:
rows = (
db.session.execute(
text(
"""
SELECT t.id, t.ticket_code
FROM tickets t
JOIN ticket_scopes ts ON ts.ticket_id = t.id
WHERE ts.job_id = :job_id
AND t.active_from_date <= :run_date
AND (
COALESCE(ts.resolved_at, t.resolved_at) IS NULL
OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
)
ORDER BY t.start_date DESC, t.id DESC
"""
),
{"job_id": int(job.id), "run_date": run_date, "ui_tz": ui_tz},
)
.fetchall()
)
except Exception:
rows = []
if not rows:
return
# Link all open tickets to this run (idempotent)
for tid, _code in rows:
if not TicketJobRun.query.filter_by(ticket_id=int(tid), job_run_id=int(run.id)).first():
db.session.add(TicketJobRun(ticket_id=int(tid), job_run_id=int(run.id), link_source="inherit"))
# Best-effort: propagate Autotask linkage if present on prior runs for the same ticket code.
# This allows new runs to keep the PSA ticket reference without requiring UI changes.
try:
if getattr(run, "autotask_ticket_id", None):
return
except Exception:
pass
try:
# Use the newest ticket code to find a matching prior Autotask-linked run.
newest_code = (rows[0][1] or "").strip()
if not newest_code:
return
prior = (
JobRun.query.filter(JobRun.job_id == job.id)
.filter(JobRun.autotask_ticket_id.isnot(None))
.filter(JobRun.autotask_ticket_number == newest_code)
.order_by(JobRun.id.desc())
.first()
)
if prior and getattr(prior, "autotask_ticket_id", None):
run.autotask_ticket_id = prior.autotask_ticket_id
run.autotask_ticket_number = prior.autotask_ticket_number
run.autotask_ticket_created_at = getattr(prior, "autotask_ticket_created_at", None)
run.autotask_ticket_created_by_user_id = getattr(prior, "autotask_ticket_created_by_user_id", None)
except Exception:
return

View File

@ -19,6 +19,11 @@
</form> </form>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('main.customers_export') }}">Export CSV</a> <a class="btn btn-outline-secondary btn-sm" href="{{ url_for('main.customers_export') }}">Export CSV</a>
{% if autotask_enabled and autotask_configured %}
<button type="button" class="btn btn-outline-secondary btn-sm" id="autotaskRefreshAllMappingsBtn" style="white-space: nowrap;">Refresh all Autotask mappings</button>
<span class="small text-muted" id="autotaskRefreshAllMappingsMsg"></span>
{% endif %}
</div> </div>
{% endif %} {% endif %}
@ -29,6 +34,8 @@
<th scope="col">Customer</th> <th scope="col">Customer</th>
<th scope="col">Active</th> <th scope="col">Active</th>
<th scope="col">Number of jobs</th> <th scope="col">Number of jobs</th>
<th scope="col">Autotask company</th>
<th scope="col">Autotask mapping</th>
{% if can_manage %} {% if can_manage %}
<th scope="col">Actions</th> <th scope="col">Actions</th>
{% endif %} {% endif %}
@ -46,6 +53,7 @@
<span class="badge bg-secondary">Inactive</span> <span class="badge bg-secondary">Inactive</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if c.job_count > 0 %} {% if c.job_count > 0 %}
{{ c.job_count }} {{ c.job_count }}
@ -53,6 +61,36 @@
<span class="text-danger fw-bold">0</span> <span class="text-danger fw-bold">0</span>
{% endif %} {% endif %}
</td> </td>
<td>
{% if c.autotask_company_id %}
<span class="fw-semibold">{{ c.autotask_company_name or 'Unknown' }}</span>
<div class="text-muted small">ID: {{ c.autotask_company_id }}</div>
{% else %}
<span class="text-muted">Not mapped</span>
{% endif %}
</td>
<td>
{% set st = (c.autotask_mapping_status or '').lower() %}
{% if not c.autotask_company_id %}
<span class="badge bg-secondary">Not mapped</span>
{% elif st == 'ok' %}
<span class="badge bg-success">OK</span>
{% elif st == 'renamed' %}
<span class="badge bg-warning text-dark">Renamed</span>
{% elif st == 'missing' %}
<span class="badge bg-warning text-dark">Missing</span>
{% elif st == 'invalid' %}
<span class="badge bg-danger">Invalid</span>
{% else %}
<span class="badge bg-secondary">Unknown</span>
{% endif %}
{% if c.autotask_last_sync_at %}
<div class="text-muted small">Checked: {{ c.autotask_last_sync_at }}</div>
{% endif %}
</td>
{% if can_manage %} {% if can_manage %}
<td> <td>
<button <button
@ -63,6 +101,10 @@
data-id="{{ c.id }}" data-id="{{ c.id }}"
data-name="{{ c.name }}" data-name="{{ c.name }}"
data-active="{{ '1' if c.active else '0' }}" data-active="{{ '1' if c.active else '0' }}"
data-autotask-company-id="{{ c.autotask_company_id or '' }}"
data-autotask-company-name="{{ c.autotask_company_name or '' }}"
data-autotask-mapping-status="{{ c.autotask_mapping_status or '' }}"
data-autotask-last-sync-at="{{ c.autotask_last_sync_at or '' }}"
> >
Edit Edit
</button> </button>
@ -82,7 +124,7 @@
{% endfor %} {% endfor %}
{% else %} {% else %}
<tr> <tr>
<td colspan="{% if can_manage %}4{% else %}3{% endif %}" class="text-center text-muted py-3"> <td colspan="{% if can_manage %}6{% else %}5{% endif %}" class="text-center text-muted py-3">
No customers found. No customers found.
</td> </td>
</tr> </tr>
@ -130,6 +172,36 @@
Active Active
</label> </label>
</div> </div>
<hr class="my-4" />
<h6 class="mb-2">Autotask mapping</h6>
{% if autotask_enabled and autotask_configured %}
<div class="mb-2">
<div class="small text-muted">Current mapping</div>
<div id="autotaskCurrentMapping" class="fw-semibold">Not mapped</div>
<div id="autotaskCurrentMappingMeta" class="text-muted small"></div>
</div>
<div class="input-group input-group-sm mb-2">
<input type="text" class="form-control" id="autotaskCompanySearch" placeholder="Search Autotask companies" autocomplete="off" />
<button class="btn btn-outline-secondary" type="button" id="autotaskCompanySearchBtn">Search</button>
</div>
<div id="autotaskCompanyResults" class="border rounded p-2" style="max-height: 220px; overflow:auto;"></div>
<div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-sm btn-outline-primary" id="autotaskSetMappingBtn" disabled>Set mapping</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="autotaskRefreshMappingBtn">Refresh status</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="autotaskClearMappingBtn">Clear mapping</button>
</div>
<div id="autotaskMappingMsg" class="small text-muted mt-2"></div>
{% else %}
<div class="text-muted small">
Autotask integration is not available. Enable and configure it in Settings → Extensions &amp; Integrations → Autotask.
</div>
{% endif %}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
@ -152,6 +224,133 @@
var nameInput = document.getElementById("edit_customer_name"); var nameInput = document.getElementById("edit_customer_name");
var activeInput = document.getElementById("edit_customer_active"); var activeInput = document.getElementById("edit_customer_active");
// Top-level refresh-all (only present when integration is enabled/configured)
var refreshAllBtn = document.getElementById("autotaskRefreshAllMappingsBtn");
var refreshAllMsg = document.getElementById("autotaskRefreshAllMappingsMsg");
// Autotask mapping UI (only present when integration is enabled/configured)
var atCurrent = document.getElementById("autotaskCurrentMapping");
var atCurrentMeta = document.getElementById("autotaskCurrentMappingMeta");
var atSearchInput = document.getElementById("autotaskCompanySearch");
var atSearchBtn = document.getElementById("autotaskCompanySearchBtn");
var atResults = document.getElementById("autotaskCompanyResults");
var atMsg = document.getElementById("autotaskMappingMsg");
var atSetBtn = document.getElementById("autotaskSetMappingBtn");
var atRefreshBtn = document.getElementById("autotaskRefreshMappingBtn");
var atClearBtn = document.getElementById("autotaskClearMappingBtn");
var currentCustomerId = null;
var selectedCompanyId = null;
function setRefreshAllMsg(text, isError) {
if (!refreshAllMsg) {
return;
}
refreshAllMsg.textContent = text || "";
if (isError) {
refreshAllMsg.classList.remove("text-muted");
refreshAllMsg.classList.add("text-danger");
} else {
refreshAllMsg.classList.remove("text-danger");
refreshAllMsg.classList.add("text-muted");
}
}
function setMsg(text, isError) {
if (!atMsg) {
return;
}
atMsg.textContent = text || "";
if (isError) {
atMsg.classList.remove("text-muted");
atMsg.classList.add("text-danger");
} else {
atMsg.classList.remove("text-danger");
atMsg.classList.add("text-muted");
}
}
function renderCurrentMapping(companyId, companyName, mappingStatus, lastSyncAt) {
if (!atCurrent || !atCurrentMeta) {
return;
}
if (!companyId) {
atCurrent.textContent = "Not mapped";
atCurrentMeta.textContent = "";
return;
}
atCurrent.textContent = (companyName || "Unknown") + " (ID: " + companyId + ")";
var parts = [];
if (mappingStatus) {
parts.push("Status: " + mappingStatus);
}
if (lastSyncAt) {
parts.push("Checked: " + lastSyncAt);
}
atCurrentMeta.textContent = parts.join(" • ");
}
function clearResults() {
if (!atResults) {
return;
}
atResults.innerHTML = "<div class=\"text-muted small\">No results.</div>";
}
function setSelectedCompanyId(cid) {
selectedCompanyId = cid;
if (atSetBtn) {
atSetBtn.disabled = !selectedCompanyId;
}
}
async function postJson(url, body) {
var resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(body || {}),
});
var data = null;
try {
data = await resp.json();
} catch (e) {
data = null;
}
if (!resp.ok) {
var msg = (data && data.message) ? data.message : ("Request failed (" + resp.status + ").");
throw new Error(msg);
}
return data;
}
if (refreshAllBtn) {
refreshAllBtn.addEventListener("click", async function () {
if (!confirm("Refresh mapping status for all mapped customers?")) {
return;
}
refreshAllBtn.disabled = true;
setRefreshAllMsg("Refreshing...", false);
try {
var data = await postJson("/api/customers/autotask-mapping/refresh-all", {});
var counts = (data && data.counts) ? data.counts : null;
if (counts) {
setRefreshAllMsg(
"Done. OK: " + (counts.ok || 0) + ", Renamed: " + (counts.renamed || 0) + ", Missing: " + (counts.missing || 0) + ", Invalid: " + (counts.invalid || 0) + ".",
false
);
} else {
setRefreshAllMsg("Done.", false);
}
window.location.reload();
} catch (e) {
setRefreshAllMsg(e && e.message ? e.message : "Refresh failed.", true);
refreshAllBtn.disabled = false;
}
});
}
var editButtons = document.querySelectorAll(".customer-edit-btn"); var editButtons = document.querySelectorAll(".customer-edit-btn");
editButtons.forEach(function (btn) { editButtons.forEach(function (btn) {
btn.addEventListener("click", function () { btn.addEventListener("click", function () {
@ -165,8 +364,140 @@
if (id) { if (id) {
editForm.action = "{{ url_for('main.customers_edit', customer_id=0) }}".replace("0", id); editForm.action = "{{ url_for('main.customers_edit', customer_id=0) }}".replace("0", id);
} }
// Autotask: seed current mapping from row data attributes
currentCustomerId = id || null;
if (atResults) {
clearResults();
}
setSelectedCompanyId(null);
setMsg("", false);
if (atCurrent) {
var atCompanyId = btn.getAttribute("data-autotask-company-id") || "";
var atCompanyName = btn.getAttribute("data-autotask-company-name") || "";
var atStatus = btn.getAttribute("data-autotask-mapping-status") || "";
var atLast = btn.getAttribute("data-autotask-last-sync-at") || "";
renderCurrentMapping(atCompanyId, atCompanyName, atStatus, atLast);
}
}); });
}); });
if (atSearchBtn && atSearchInput && atResults) {
atSearchBtn.addEventListener("click", async function () {
var q = (atSearchInput.value || "").trim();
if (!q) {
setMsg("Enter a search term.", true);
return;
}
setMsg("Searching...", false);
setSelectedCompanyId(null);
atResults.innerHTML = "<div class=\"text-muted small\">Searching...</div>";
try {
var resp = await fetch("/api/autotask/companies/search?q=" + encodeURIComponent(q), {
method: "GET",
credentials: "same-origin",
});
var data = await resp.json();
if (!resp.ok || !data || data.status !== "ok") {
throw new Error((data && data.message) ? data.message : "Search failed.");
}
var items = data.items || [];
if (!items.length) {
atResults.innerHTML = "<div class=\"text-muted small\">No companies found.</div>";
setMsg("No companies found.", false);
return;
}
var html = "";
items.forEach(function (it) {
var cid = it.id;
var name = it.companyName || it.name || ("Company #" + cid);
var active = (it.isActive === false) ? " (inactive)" : "";
html +=
"<div class=\"form-check\">" +
"<input class=\"form-check-input\" type=\"radio\" name=\"autotaskCompanyPick\" id=\"at_company_" + cid + "\" value=\"" + cid + "\" />" +
"<label class=\"form-check-label\" for=\"at_company_" + cid + "\">" +
name.replace(/</g, "&lt;").replace(/>/g, "&gt;") +
" <span class=\"text-muted\">(ID: " + cid + ")</span>" +
"<span class=\"text-muted\">" + active + "</span>" +
"</label>" +
"</div>";
});
atResults.innerHTML = html;
var radios = atResults.querySelectorAll("input[name='autotaskCompanyPick']");
radios.forEach(function (r) {
r.addEventListener("change", function () {
setSelectedCompanyId(r.value);
setMsg("Selected company ID: " + r.value, false);
});
});
setMsg("Select a company and click Set mapping.", false);
} catch (e) {
atResults.innerHTML = "<div class=\"text-muted small\">No results.</div>";
setMsg(e && e.message ? e.message : "Search failed.", true);
}
});
}
if (atSetBtn) {
atSetBtn.addEventListener("click", async function () {
if (!currentCustomerId) {
setMsg("No customer selected.", true);
return;
}
if (!selectedCompanyId) {
setMsg("Select a company first.", true);
return;
}
atSetBtn.disabled = true;
setMsg("Saving mapping...", false);
try {
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping", { company_id: selectedCompanyId });
window.location.reload();
} catch (e) {
setMsg(e && e.message ? e.message : "Failed to set mapping.", true);
atSetBtn.disabled = false;
}
});
}
if (atRefreshBtn) {
atRefreshBtn.addEventListener("click", async function () {
if (!currentCustomerId) {
setMsg("No customer selected.", true);
return;
}
setMsg("Refreshing status...", false);
try {
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping/refresh", {});
window.location.reload();
} catch (e) {
setMsg(e && e.message ? e.message : "Refresh failed.", true);
}
});
}
if (atClearBtn) {
atClearBtn.addEventListener("click", async function () {
if (!currentCustomerId) {
setMsg("No customer selected.", true);
return;
}
if (!confirm("Clear Autotask mapping for this customer?")) {
return;
}
setMsg("Clearing mapping...", false);
try {
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping/clear", {});
window.location.reload();
} catch (e) {
setMsg(e && e.message ? e.message : "Clear failed.", true);
}
});
}
}); });
})(); })();
</script> </script>

View File

@ -214,18 +214,16 @@
<div id="rcm_alerts" class="small"></div> <div id="rcm_alerts" class="small"></div>
<div class="mt-2"> <div class="mt-2">
<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">
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
<div class="fw-semibold">New ticket</div> <div class="fw-semibold">Autotask ticket</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button> <button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button>
</div> </div>
<div class="mt-2"> <div class="mt-2 small" id="rcm_autotask_info"></div>
<input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" /> <div class="mt-2 small text-muted" id="rcm_autotask_status"></div>
</div> </div>
<div class="mt-2 small text-muted" id="rcm_ticket_status"></div> </div>
</div>
</div>
<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">
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
@ -299,10 +297,48 @@
var currentRunId = null; var currentRunId = null;
var currentPayload = null; var currentPayload = null;
// Phase 2.1: Read-only Autotask ticket polling (Run Checks page only)
// Cache shape: { <ticketId>: {id, ticketNumber, status, statusName, title, lastActivityDate} }
var autotaskTicketPollCache = {};
function pollAutotaskTicketsOnPageOpen() {
// Only execute on Run Checks page load.
var url = '/api/run-checks/autotask-ticket-poll';
var qs = [];
// include_reviewed is only meaningful for admins
try {
var includeReviewed = {{ 'true' if include_reviewed else 'false' }};
if (includeReviewed) qs.push('include_reviewed=1');
} catch (e) {}
if (qs.length) url += '?' + qs.join('&');
fetch(url)
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j || j.status !== 'ok') return;
autotaskTicketPollCache = {};
var list = (j.tickets || []);
for (var i = 0; i < list.length; i++) {
var t = list[i] || {};
var id = parseInt(t.id, 10);
if (!Number.isFinite(id) || id <= 0) continue;
autotaskTicketPollCache[id] = t;
}
window.__rcAutotaskTicketPollCache = autotaskTicketPollCache;
})
.catch(function () {
autotaskTicketPollCache = {};
window.__rcAutotaskTicketPollCache = autotaskTicketPollCache;
});
}
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');
// Shift-click range selection for checkbox rows pollAutotaskTicketsOnPageOpen();
// Shift-click range selection for checkbox rows
var lastCheckedCb = null; var lastCheckedCb = null;
@ -841,56 +877,99 @@ table.addEventListener('change', function (e) {
} }
function bindInlineCreateForms() { function bindInlineCreateForms() {
var btnTicket = document.getElementById('rcm_ticket_save'); var btnAutotask = document.getElementById('rcm_autotask_create');
var atInfo = document.getElementById('rcm_autotask_info');
var atStatus = document.getElementById('rcm_autotask_status');
var btnRemark = document.getElementById('rcm_remark_save'); var btnRemark = document.getElementById('rcm_remark_save');
var tCode = document.getElementById('rcm_ticket_code');
var tStatus = document.getElementById('rcm_ticket_status');
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 (tStatus) tStatus.textContent = ''; if (atStatus) atStatus.textContent = '';
if (rStatus) rStatus.textContent = ''; if (rStatus) rStatus.textContent = '';
} }
function setDisabled(disabled) { function setDisabled(disabled) {
if (btnTicket) btnTicket.disabled = disabled; if (btnAutotask) btnAutotask.disabled = disabled;
if (btnRemark) btnRemark.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled;
if (tCode) tCode.disabled = disabled; if (rBody) rBody.disabled = disabled;
if (rBody) rBody.disabled = disabled;
} }
window.__rcmSetCreateDisabled = setDisabled; window.__rcmSetCreateDisabled = setDisabled;
window.__rcmClearCreateStatus = clearStatus; window.__rcmClearCreateStatus = clearStatus;
if (btnTicket) { function renderAutotaskInfo(run) {
btnTicket.addEventListener('click', function () { if (!atInfo) return;
var num = (run && run.autotask_ticket_number) ? String(run.autotask_ticket_number) : '';
var tid = (run && run.autotask_ticket_id) ? parseInt(run.autotask_ticket_id, 10) : null;
var polled = (tid && autotaskTicketPollCache && autotaskTicketPollCache[tid]) ? autotaskTicketPollCache[tid] : null;
var lines = [];
if (num) {
lines.push('<div><strong>Ticket:</strong> ' + escapeHtml(num) + '</div>');
} else if (tid) {
lines.push('<div><strong>Ticket:</strong> created</div>');
} else {
lines.push('<div class="text-muted">No Autotask ticket created for this run.</div>');
}
// Phase 2.1 visibility only: show last polled status if available
if (tid) {
if (polled) {
var statusName = (polled.statusName || '').toString().trim();
var statusVal = (polled.status !== undefined && polled.status !== null) ? String(polled.status) : '';
var label = statusName ? statusName : (statusVal ? ('Status ' + statusVal) : '');
if (label) {
lines.push('<div class="text-muted">PSA status (polled): ' + escapeHtml(label) + '</div>');
}
} else {
lines.push('<div class="text-muted">PSA status (polled): not available</div>');
}
}
atInfo.innerHTML = lines.join('');
}
window.__rcmRenderAutotaskInfo = renderAutotaskInfo;
if (btnAutotask) {
btnAutotask.addEventListener('click', function () {
if (!currentRunId) { alert('Select a run first.'); return; } if (!currentRunId) { alert('Select a run first.'); return; }
clearStatus(); clearStatus();
var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; if (atStatus) atStatus.textContent = 'Creating ticket...';
if (!ticket_code) { btnAutotask.disabled = true;
if (tStatus) tStatus.textContent = 'Ticket number is required.'; apiJson('/api/run-checks/autotask-ticket', {
else alert('Ticket number is required.');
return;
}
if (!/^T\d{8}\.\d{4}$/.test(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', method: 'POST',
body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code}) body: JSON.stringify({run_id: currentRunId})
}) })
.then(function () { .then(function (j) {
if (tCode) tCode.value = ''; if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
if (tStatus) tStatus.textContent = ''; if (atStatus) atStatus.textContent = '';
loadAlerts(currentRunId);
// 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;
// Find the same run index
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; }
}
// Re-render the currently open Run Checks modal with fresh data.
renderRun(payload, idx);
});
}
}) })
.catch(function (e) { .catch(function (e) {
if (tStatus) tStatus.textContent = e.message || 'Failed.'; if (atStatus) atStatus.textContent = e.message || 'Failed.';
else alert(e.message || 'Failed.'); else alert(e.message || 'Failed.');
})
.finally(function () {
// State will be recalculated by renderRun.
}); });
}); });
} }
@ -956,7 +1035,8 @@ if (tStatus) tStatus.textContent = '';
currentRunId = run.id || null; currentRunId = run.id || null;
if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus(); if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus();
if (window.__rcmSetCreateDisabled) window.__rcmSetCreateDisabled(!currentRunId); if (window.__rcmRenderAutotaskInfo) window.__rcmRenderAutotaskInfo(run);
if (window.__rcmSetCreateDisabled) window.__rcmSetCreateDisabled(!currentRunId || !!run.autotask_ticket_id);
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);
@ -1144,9 +1224,10 @@ if (tStatus) tStatus.textContent = '';
var dot = run.missed ? "dot-missed" : statusDotClass(run.status); var dot = run.missed ? "dot-missed" : statusDotClass(run.status);
var dotHtml = dot ? ('<span class="status-dot ' + dot + ' me-2" aria-hidden="true"></span>') : ''; var dotHtml = dot ? ('<span class="status-dot ' + dot + ' me-2" aria-hidden="true"></span>') : '';
var reviewedMark = run.is_reviewed ? ' <span class="ms-2" title="Reviewed" aria-label="Reviewed"></span>' : ''; var reviewedMark = run.is_reviewed ? ' <span class="ms-2" title="Reviewed" aria-label="Reviewed"></span>' : '';
var ticketMark = run.autotask_ticket_id ? ' <span class="ms-2" title="Autotask ticket created" aria-label="Autotask ticket">🎫</span>' : '';
a.title = run.status || ''; a.title = run.status || '';
a.innerHTML = dotHtml + '<span class="text-nowrap">' + escapeHtml(run.run_at || 'Run') + '</span>' + reviewedMark; a.innerHTML = dotHtml + '<span class="text-nowrap">' + escapeHtml(run.run_at || 'Run') + '</span>' + reviewedMark + ticketMark;
a.addEventListener('click', function (ev) { a.addEventListener('click', function (ev) {
ev.preventDefault(); ev.preventDefault();
renderRun(data, idx); renderRun(data, idx);

View File

@ -20,6 +20,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if section == 'imports' %}active{% endif %}" href="{{ url_for('main.settings', section='imports') }}">Imports</a> <a class="nav-link {% if section == 'imports' %}active{% endif %}" href="{{ url_for('main.settings', section='imports') }}">Imports</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if section == 'integrations' %}active{% endif %}" href="{{ url_for('main.settings', section='integrations') }}">Integrations</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if section == 'maintenance' %}active{% endif %}" href="{{ url_for('main.settings', section='maintenance') }}">Maintenance</a> <a class="nav-link {% if section == 'maintenance' %}active{% endif %}" href="{{ url_for('main.settings', section='maintenance') }}">Maintenance</a>
</li> </li>
@ -316,6 +319,163 @@
{% endif %} {% endif %}
{% if section == 'integrations' %}
<form method="post" class="mb-4">
<div class="card mb-3">
<div class="card-header">Autotask</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="autotask_enabled" name="autotask_enabled" {% if settings.autotask_enabled %}checked{% endif %} />
<label class="form-check-label" for="autotask_enabled">Enable Autotask integration</label>
</div>
<div class="row g-3">
<div class="col-md-4">
<label for="autotask_environment" class="form-label">Environment</label>
<select class="form-select" id="autotask_environment" name="autotask_environment">
<option value="" {% if not settings.autotask_environment %}selected{% endif %}>Select...</option>
<option value="sandbox" {% if settings.autotask_environment == 'sandbox' %}selected{% endif %}>Sandbox</option>
<option value="production" {% if settings.autotask_environment == 'production' %}selected{% endif %}>Production</option>
</select>
<div class="form-text">Use Sandbox for testing first.</div>
</div>
<div class="col-md-4">
<label for="autotask_api_username" class="form-label">API Username</label>
<input type="text" class="form-control" id="autotask_api_username" name="autotask_api_username" value="{{ settings.autotask_api_username or '' }}" />
</div>
<div class="col-md-4">
<label for="autotask_api_password" class="form-label">API Password</label>
<input
type="password"
class="form-control"
id="autotask_api_password"
name="autotask_api_password"
placeholder="{% if has_autotask_password %}******** (stored){% else %}enter password{% endif %}"
/>
<div class="form-text">Leave empty to keep the existing password.</div>
</div>
<div class="col-md-6">
<label for="autotask_tracking_identifier" class="form-label">Tracking Identifier (Integration Code)</label>
<input type="text" class="form-control" id="autotask_tracking_identifier" name="autotask_tracking_identifier" value="{{ settings.autotask_tracking_identifier or '' }}" />
</div>
<div class="col-md-6">
<label for="autotask_base_url" class="form-label">Backupchecks Base URL</label>
<input type="text" class="form-control" id="autotask_base_url" name="autotask_base_url" value="{{ settings.autotask_base_url or '' }}" placeholder="https://backupchecks.example.com" />
<div class="form-text">Required later for creating stable links to Job Details pages.</div>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Ticket defaults</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label for="autotask_default_queue_id" class="form-label">Default Queue</label>
<select class="form-select" id="autotask_default_queue_id" name="autotask_default_queue_id">
<option value="" {% if not settings.autotask_default_queue_id %}selected{% endif %}>Select...</option>
{% for q in autotask_queues %}
<option value="{{ q.id }}" {% if settings.autotask_default_queue_id == q.id %}selected{% endif %}>{{ q.name }}</option>
{% endfor %}
</select>
<div class="form-text">Requires refreshed reference data.</div>
</div>
<div class="col-md-6">
<label for="autotask_default_ticket_source_id" class="form-label">Ticket Source</label>
<select class="form-select" id="autotask_default_ticket_source_id" name="autotask_default_ticket_source_id">
<option value="" {% if not settings.autotask_default_ticket_source_id %}selected{% endif %}>Select...</option>
{% for s in autotask_ticket_sources %}
<option value="{{ s.id }}" {% if settings.autotask_default_ticket_source_id == s.id %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
<div class="form-text">Requires refreshed reference data.</div>
</div>
<div class="col-md-6">
<label for="autotask_default_ticket_status" class="form-label">Default Ticket Status</label>
<select class="form-select" id="autotask_default_ticket_status" name="autotask_default_ticket_status">
<option value="" {% if not settings.autotask_default_ticket_status %}selected{% endif %}>Select...</option>
{% for st in autotask_ticket_statuses %}
<option value="{{ st.id }}" {% if settings.autotask_default_ticket_status == st.id %}selected{% endif %}>{{ st.name }}</option>
{% endfor %}
</select>
<div class="form-text">Required for Autotask ticket creation. Requires refreshed reference data.</div>
</div>
<div class="col-md-6">
<label for="autotask_priority_warning" class="form-label">Priority for Warning</label>
<select class="form-select" id="autotask_priority_warning" name="autotask_priority_warning">
<option value="" {% if not settings.autotask_priority_warning %}selected{% endif %}>Select...</option>
{% for p in autotask_priorities %}
<option value="{{ p.id }}" {% if settings.autotask_priority_warning == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
<div class="form-text">Requires refreshed reference data.</div>
</div>
<div class="col-md-6">
<label for="autotask_priority_error" class="form-label">Priority for Error</label>
<select class="form-select" id="autotask_priority_error" name="autotask_priority_error">
<option value="" {% if not settings.autotask_priority_error %}selected{% endif %}>Select...</option>
{% for p in autotask_priorities %}
<option value="{{ p.id }}" {% if settings.autotask_priority_error == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
<div class="form-text">Requires refreshed reference data.</div>
</div>
</div>
<div class="form-text mt-2">Priorities are loaded from Autotask to avoid manual ID mistakes.</div>
</div>
</div>
<div class="d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-primary">Save settings</button>
</div>
</form>
<div class="card mb-4">
<div class="card-header">Diagnostics & reference data</div>
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-6">
<div class="text-muted small">Last reference data sync</div>
<div class="fw-semibold">
{% if autotask_last_sync_at %}
{{ autotask_last_sync_at }}
{% else %}
never
{% endif %}
</div>
<div class="text-muted small mt-2">
Cached Queues: {{ autotask_queues|length }}<br />
Cached Ticket Sources: {{ autotask_ticket_sources|length }}<br />
Cached Ticket Statuses: {{ autotask_ticket_statuses|length }}<br />
Cached Priorities: {{ autotask_priorities|length }}
</div>
</div>
<div class="col-md-6">
<div class="d-flex flex-wrap gap-2 justify-content-md-end">
<form method="post" action="{{ url_for('main.settings_autotask_test_connection') }}">
<button type="submit" class="btn btn-outline-secondary">Test connection</button>
</form>
<form method="post" action="{{ url_for('main.settings_autotask_refresh_reference_data') }}">
<button type="submit" class="btn btn-outline-primary">Refresh reference data</button>
</form>
</div>
<div class="form-text mt-2 text-md-end">Refresh loads Queues, Ticket Sources, Ticket Statuses, and Priorities from Autotask for dropdown usage.</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if section == 'maintenance' %} {% if section == 'maintenance' %}
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">

View File

@ -0,0 +1,412 @@
# Backupchecks Autotask Integration
## Functional Design
*Last updated: 2026-01-16*
---
## 1. Scope & Goals
This document describes the **functional design and agreed decisions** for the first phase of the Autotask integration in Backupchecks.
Goals for phase 1:
- Allow operators to **manually create Autotask tickets** from Backupchecks.
- Ensure **full operator control** over when a ticket is created.
- Prevent ticket spam and duplicate tickets.
- Maintain clear ownership between Backupchecks and Autotask.
- Provide a safe and auditable way to resolve tickets from Backupchecks.
Out of scope for phase 1:
- Automatic ticket creation
- Automatic ticket closing on success
- Issue correlation across multiple runs
- Time entry creation or modification
---
## 2. Core Principles (Leading)
These principles apply to all design and implementation choices:
- Autotask is an **external authoritative system** (PSA).
- Backupchecks is a **consumer**, not an owner, of PSA data.
- **IDs are leading**, names are display-only.
- All PSA mappings are **explicit**, never implicit or automatic.
- Operators always retain **manual control**.
- Renaming in Autotask must **never break mappings**.
---
## 3. Customer ↔ Autotask Company Mapping
### 3.1 Mapping model
- Mapping is configured in the **Customers** screen.
- Mapping is a **1-to-1 explicit relationship**.
- Stored values per customer:
- PSA type: autotask
- Autotask Company ID (leading)
- Autotask Company Name (cached for display)
- Last sync timestamp
- Mapping status: ok | renamed | missing | invalid
The Autotask Company ID is the source of truth. The name exists only for UI clarity.
### 3.2 Name synchronisation
- If the company name is changed in Autotask:
- Backupchecks updates the cached name automatically.
- The mapping remains intact.
- Backupchecks customer names are independent and never overwritten.
### 3.3 Failure scenarios
- Autotask company deleted or inaccessible:
- Mapping status becomes invalid.
- Ticket creation is blocked.
- UI clearly indicates broken mapping.
---
## 4. Ticket Creation Model
### 4.1 Operator-driven creation
- Tickets are created only via an explicit operator action.
- Location: Run Checks page.
- Manual ticket number input is removed.
- A new action replaces it: Create Autotask ticket.
### 4.2 One ticket per run
- Exactly one ticket per run.
- A run can never create multiple tickets.
- If a ticket exists:
- Creation action is replaced by Open ticket.
### 4.3 Ticket contents (baseline)
Minimum ticket fields:
- Subject: [Backupchecks] - -&#x20;
- Description:
- Run date/time
- Backup type and job
- Affected objects
- Error or warning messages
- Reference to Backupchecks
---
## 5. Ticket State Tracking in Backupchecks
Per run, Backupchecks stores:
- Autotask Ticket ID
- Autotask Ticket Number
- Ticket URL
- Created by
- Created at timestamp
- Last known ticket status (snapshot)
---
## 6. Ticket Resolution from Backupchecks
Backupchecks may resolve a ticket only if:
- The ticket exists
- The ticket is not already closed
- No time entries are present
If time entries exist, the ticket is not closed and an internal system note is added.
---
## 7. Backupchecks Settings
Settings → Extensions & Integrations → Autotask
Includes:
- Enable integration (on/off)
- Environment
- API credentials
- Tracking Identifier
- Default queue and status
- Priority mapping
### 7.1 Enable / disable behaviour (mandatory)
Backupchecks must support switching the Autotask integration **on and off at any time**.
When Autotask integration is **enabled**:
- Autotask actions are available (create ticket, resolve ticket, link existing ticket).
- Ticket polling/synchronisation (Phase 2) is active.
When Autotask integration is **disabled**:
- Backupchecks falls back to the manual workflow:
- Operators can manually enter ticket numbers.
- No Autotask API calls are executed.
- No polling is performed.
- No resolve action is available (as it would require Autotask calls).
- Existing stored Autotask references remain visible for audit/history.
The enable/disable switch must be reversible and must not require data deletion or migration.
---
## 8. Roles & Permissions
- Admin / Operator: create and resolve tickets
- Reporter: view-only access
---
## 9. Explicit Non-Goals (Phase 1)
- Automatic ticket creation
- Automatic ticket closing
- Time entry handling
- Multiple tickets per run
- PSA-side logic
---
## 10. Phase 1 Summary
Phase 1 delivers controlled, operator-driven PSA integration with a strong focus on auditability and predictability.
---
## 11. Phase 2 Ticket State Synchronisation (PSA-driven)
This phase introduces **PSA-driven state awareness** for already linked tickets. The goal is to keep Backupchecks aligned with Autotask **without introducing active control or automation**.
### 11.1 Polling strategy (Run Checks entry point)
- Polling is executed **only when Autotask integration is enabled**.
- When the **Run Checks** page is opened, Backupchecks performs a **targeted poll** to Autotask.
- When the **Run Checks** page is opened, Backupchecks performs a **targeted poll** to Autotask.
- Only tickets that are:
- Linked to runs shown on the page, and
- Not in a terminal state inside Backupchecks
are included.
- Tickets already marked as resolved or broken are excluded.
This prevents unnecessary API calls and limits polling to operator-relevant context.
### 11.2 Active-ticket-only retrieval
- Backupchecks only queries tickets that are considered **active** in Autotask.
- Completed or closed tickets are not included in the active-ticket query.
- This ensures minimal load and avoids repeated retrieval of historical data.
### 11.3 PSA-driven completion handling
If Autotask reports a ticket with status **Completed**:
- The linked run in Backupchecks is automatically marked as **Resolved**.
- The resolution is explicitly flagged as:
- **Resolved by PSA**
- Backupchecks does not add notes or modify the ticket in Autotask.
UI behaviour:
- Operators can clearly see that the resolution originated from the PSA.
- A visual indicator highlights that the underlying backup issue may still require verification.
### 11.4 Operator awareness and follow-up
When a ticket is resolved by PSA:
- Backupchecks does not assume the technical issue is resolved.
- Operators are expected to:
- Review the related run
- Decide whether further action is required inside Backupchecks
No automatic reopening or ticket creation is performed.
### 11.5 Deleted ticket detection
If a linked ticket is **deleted in Autotask**:
- Backupchecks detects this during polling.
- The ticket linkage is marked as:
- **Deleted in PSA**
UI behaviour:
- A clear warning is shown to the operator.
- The historical ticket reference remains visible for audit purposes.
- Ticket-related actions are blocked until the operator:
- Links a replacement ticket, or
- Creates a new Autotask ticket
### 11.6 Ticket resolution from Backupchecks (operator-driven)
Phase 2 includes implementation of **manual ticket resolution** from Backupchecks under the already defined Phase 1 rules.
- Resolution is always an explicit operator action (no automation).
- Resolution rules remain leading (see Chapter 6):
- Ticket must exist
- Ticket must not already be closed
- Ticket may only be closed by Backupchecks if **no time entries** exist
- If time entries exist, Backupchecks adds an internal system note and leaves the ticket open
UI behaviour (Run Checks and Job Details):
- Show a Resolve ticket action only when a validated Autotask Ticket ID exists.
- When resolution succeeds, update the run to Resolved and store:
- Resolved timestamp
- Resolved by (operator)
- Resolution origin: Resolved by Backupchecks
Important alignment with PSA-driven sync:
- If Autotask later reports the ticket as Completed, Backupchecks keeps the run resolved, but the origin remains:
- Resolved by Backupchecks
- If Autotask reports completion before the operator resolves it, Backupchecks sets:
- Resolved by PSA
### 11.7 Explicit non-goals (Phase 2)
The following remain explicitly out of scope:
- Automatic ticket creation
- Automatic ticket reopening
- Automatic resolution without operator intent
- Status pushing from Backupchecks to Autotask (except the explicit Resolve action described above)
- Any modification of existing Autotask ticket content (except fixed-format internal system notes used during resolution rules)
---
## 12. Future Design Considerations (Post-Phase 2)
The following sections describe **future design intent only**. They are explicitly **out of scope for Phase 2** and introduce no implementation commitments.
### 12.1 Ticket lifecycle awareness (read-only intelligence)
Objective:
Provide better insight into the state of linked tickets without adding actions.
Scope:
- Periodic read-only retrieval of ticket status
- Display of:
- Current status
- Queue
- Assigned resource (owner)
Shown in:
- Run Checks
- Job Details
- Tickets / Remarks overview
Explicitly excluded:
- No automatic actions
- No status synchronisation back to Autotask
Value:
- Operators need to open Autotask less frequently
- Faster visibility into whether a ticket has been picked up
### 12.2 Operator notes & context enrichment
Objective:
Add contextual information to existing tickets without taking ownership away from the PSA.
Scope:
- Add internal/system notes from Backupchecks
- Notes are always written in a fixed format
- Notes are added only by explicit operator action
Rules:
- Never overwrite existing ticket content
- Never create customer-facing notes
- Never generate notes automatically
Value:
- Tickets remain up to date without duplication
- Autotask remains the authoritative system
### 12.3 Controlled assistance (semi-automatic support)
Objective:
Support operator decision-making without introducing automation.
Examples:
- Suggestion: a similar error already has an open ticket
- Suggestion: previous run was successful, consider resolving the ticket
- Highlight repeated failures without any linked ticket
Important constraints:
- Suggestions only
- No automatic ticket creation
- No automatic ticket closure
Value:
- Reduced human error
- No loss of operator control
### 12.4 Cross-run correlation (analytical)
Objective:
Provide insight into structural or recurring problems before tickets are created.
Scope:
- Detection of repeated failures across multiple runs
- Detection of the same object with the same error over time
- Visualisation inside Backupchecks only
Explicitly excluded:
- Ticket bundling
- Ticket merging
- Any Autotask-side modifications
Value:
- Better decisions prior to ticket creation
- Reduced noise in the PSA
### 12.5 Multi-PSA abstraction (design preparation)
Objective:
Prepare the internal design for future PSA support without implementing it.
Scope:
- PSA-agnostic internal models:
- Ticket
- Company mapping
- Resolution rules
- Autotask remains the only concrete implementation
Why this is documented now:
- Prevents Autotask-specific technical debt
- Keeps the architecture open for other PSA platforms
### 12.6 Governance & audit depth
Objective:
Ensure full traceability for MSP and enterprise environments.
Scope:
- Extended audit logging:
- Who created, linked or resolved a ticket
- When the action occurred
- From which run the action originated
- Read-only export capabilities
- Optional compliance-oriented views
Value:
- MSP and enterprise readiness
- Supports internal and external audits
### 12.7 Explicit non-directions
The following are intentionally excluded unless a strategic decision is made later:
- Automatic ticket creation
- Automatic ticket handling
- Time registration
- Mutation of existing ticket content
- PSA business logic inside Backupchecks

View File

@ -0,0 +1,205 @@
# Backupchecks Autotask Integration
## Implementation Breakdown & Validation Plan
_Last updated: 2026-01-13_
---
## 1. Purpose of this document
This document describes the **logical breakdown of the Autotask integration into implementation phases**.
It is intended to:
- Provide context at the start of each development chat
- Keep focus on the **overall goal** while working step by step
- Ensure each phase is independently testable and verifiable
- Prevent scope creep during implementation
This document complements:
- *Backupchecks Autotask Integration Functional Design (Phase 1)*
---
## 2. Guiding implementation principles
- Implement in **small, validated steps**
- Each phase must be:
- Testable in isolation
- Reviewable without knowledge of later phases
- No UI or workflow assumptions beyond the current phase
- Sandbox-first development
- No breaking changes without explicit intent
---
## 3. Implementation phases
### Phase 1 Autotask integration foundation
**Goal:** Establish a reliable, testable Autotask integration layer.
Scope:
- Autotask client/service abstraction
- Authentication handling
- Tracking Identifier usage
- Environment selection (Sandbox / Production)
- Test connection functionality
- Fetch reference data:
- Queues
- Ticket Sources
Out of scope:
- UI integration (except minimal test hooks)
- Ticket creation
- Customer mapping
Validation criteria:
- Successful authentication against Sandbox
- Reference data can be retrieved and parsed
- Clear error handling for auth and API failures
---
### Phase 2 Settings integration
**Goal:** Persist and validate Autotask configuration in Backupchecks.
Scope:
- New Settings section:
- Extensions & Integrations → Autotask
- Store:
- Enable/disable toggle
- Environment
- API credentials
- Tracking Identifier
- Backupchecks Base URL
- Ticket defaults (queue, source, priorities)
- Dropdowns populated from live Autotask reference data
- Test connection & refresh reference data actions
Out of scope:
- Customer mapping
- Ticket creation
Validation criteria:
- Settings can be saved and reloaded
- Invalid configurations are blocked
- Reference data reflects Autotask configuration
---
### Phase 3 Customer to Autotask company mapping
**Goal:** Establish stable, ID-based customer mappings.
Scope:
- Customer screen enhancements
- Search/select Autotask companies
- Store company ID + cached name
- Detect and reflect renamed or deleted companies
- Mapping status indicators
Out of scope:
- Ticket creation
- Run-level logic
Validation criteria:
- Mapping persists correctly
- Renaming in Autotask does not break linkage
- Deleted companies are detected and reported
---
### Phase 4 Ticket creation from Run Checks
**Goal:** Allow operators to create Autotask tickets from Backupchecks runs.
Scope:
- “Create Autotask ticket” action
- Ticket payload composition rules
- Priority mapping (Warning / Error)
- Queue, source, status defaults
- Job Details page link inclusion
- Store ticket ID and number
Out of scope:
- Ticket resolution
- Linking existing tickets
Validation criteria:
- Exactly one ticket per run
- Tickets contain correct content and links
- No duplicate tickets can be created
---
### Phase 5 Ticket resolution flows
**Goal:** Safely resolve tickets from Backupchecks.
Scope:
- Resolve without time entries:
- Internal note
- Close ticket
- Resolve with time entries:
- Internal note only
- Ticket remains open
- All notes stored as internal/system notes
Out of scope:
- Automatic resolution
- Time entry creation
Validation criteria:
- Time entry checks enforced
- Correct notes added in all scenarios
- Ticket status reflects expected behaviour
---
### Phase 6 Integration disable & compatibility behaviour
**Goal:** Ensure safe fallback and migration support.
Scope:
- Disable Autotask integration globally
- Restore manual ticket number workflow
- Optional compatibility mode:
- Allow manual ticket number entry while integration enabled
- Link existing Autotask tickets to runs
Validation criteria:
- No Autotask API calls when integration is disabled
- Existing data remains visible
- Operators can safely transition between workflows
---
## 4. Usage in development chats
For each development chat:
- Include this document
- Include the Functional Design document
- Clearly state:
- Current phase
- Current branch name
- Provided source/zip (if applicable)
This ensures:
- Shared context
- Focused discussions
- Predictable progress
---
## 5. Summary
This breakdown ensures the Autotask integration is:
- Predictable
- Auditable
- Incrementally delivered
- Easy to reason about during implementation
Each phase builds on the previous one without hidden dependencies.

View File

@ -0,0 +1,432 @@
# Backupchecks Autotask Integration
## Phase 2 Implementation Design
*Document type: Implementation design*
*Scope: Phase 2 only*
---
## 1. Purpose
This document describes the **technical implementation approach** for Phase 2 of the Autotask integration. Phase 2 focuses on **ticket state synchronisation and operator-driven resolution**, based on the approved functional design.
No future concepts or postPhase 2 ideas are included in this document.
---
## 2. Preconditions
Phase 2 assumes:
- Phase 1 is fully implemented and deployed
- Autotask integration can be enabled and disabled at runtime
- Tickets are already linked to runs via Autotask Ticket ID
- Backupchecks is not the authoritative system for ticket state
---
## 3. High-level Behaviour Overview
Phase 2 is implemented in **controlled sub-phases**. Each phase introduces a limited set of actions and ends with an explicit **functional validation moment** before continuing.
The sub-phases are:
- Phase 2.1 Read-only ticket polling
- Phase 2.2 PSA-driven resolution handling
- Phase 2.3 Deleted ticket detection
- Phase 2.4 Manual ticket resolution from Backupchecks
---
## 4. Phase 2.1 Read-only Ticket Polling
### 4.1 Scope
This phase introduces **read-only polling** of Autotask ticket state. No mutations or resolution logic is applied.
### 4.2 Trigger point
Polling is triggered when:
- Autotask integration is enabled
- The **Run Checks** page is opened
There is no background scheduler or periodic task.
### 4.3 Polling scope
Only tickets that meet **all** of the following criteria are included:
- Ticket is linked to a run visible on the Run Checks page
- Run is not already resolved in Backupchecks
- Ticket is not marked as deleted or invalid
### 4.4 Autotask query strategy
- Query only **active tickets** in Autotask
- Ticket ID is always used as the lookup key
### 4.5 Control moment Phase 2.1
Functional validation:
- Run Checks page loads without delay
- Correct tickets are polled
- No state changes occur in Backupchecks
- No Autotask data is modified
---
### 4.2 Polling scope
Only tickets that meet **all** of the following criteria are included:
- Ticket is linked to a run visible on the Run Checks page
- Run is not already resolved in Backupchecks
- Ticket is not marked as deleted or invalid
This guarantees:
- Minimal API usage
- Operator-context-only data retrieval
---
### 4.3 Autotask query strategy
- Query only **active tickets** in Autotask
- Completed / closed tickets are excluded from the active query
- Ticket ID is always used as the lookup key
If an expected ticket is not returned:
- A follow-up single-ticket lookup is executed
- If still not found, the ticket is treated as **deleted in PSA**
---
## 5. Phase 2.2 PSA-driven Resolution Handling
### 5.1 Scope
This phase adds **state interpretation** on top of read-only polling.
### 5.2 Completed ticket handling
If Autotask reports the ticket status as **Completed**:
- Mark the linked run as **Resolved**
- Set resolution origin to:
- `Resolved by PSA`
- Store resolution timestamp
No write-back to Autotask is performed.
### 5.3 Active ticket handling
If the ticket exists and is active:
- Update cached ticket status snapshot only
- No state change in Backupchecks
### 5.4 Control moment Phase 2.2
Functional validation:
- PSA-completed tickets resolve runs correctly
- Resolution origin is shown correctly
- Active tickets do not alter run state
---
### 5.2 Completed ticket
If Autotask reports the ticket status as **Completed**:
- Mark the linked run as **Resolved**
- Set resolution origin to:
- `Resolved by PSA`
- Store resolution timestamp
No write-back to Autotask is performed.
---
### 5.3 Deleted or missing ticket
If the ticket cannot be found in Autotask:
- Mark ticket linkage as:
- `Deleted in PSA`
- Block ticket-related actions
- Preserve historical references (ID, number, URL)
Operator must explicitly link or create a replacement ticket.
---
## 6. Phase 2.3 Deleted Ticket Detection
### 6.1 Scope
This phase introduces detection of tickets that are removed from Autotask.
### 6.2 Detection logic
If a linked ticket is not returned by the active-ticket query:
- Execute a single-ticket lookup
- If still not found:
- Mark ticket linkage as `Deleted in PSA`
### 6.3 Behaviour
- Ticket actions are blocked
- Historical references remain visible
- Operator must explicitly relink or recreate a ticket
### 6.4 Control moment Phase 2.3
Functional validation:
- Deleted tickets are detected reliably
- Warning is visible in UI
- No silent unlinking occurs
---
## 7. Phase 2.4 Manual Ticket Resolution (Backupchecks → Autotask)
### 7.1 Preconditions
The Resolve action is available only if:
- Autotask integration is enabled
- A valid Autotask Ticket ID exists
- Ticket is not already closed in PSA
### 7.2 Resolution flow
1. Operator triggers **Resolve ticket**
2. Backupchecks retrieves ticket details from Autotask
3. Time entry check is executed
#### Case A No time entries
- Ticket is set to completed in Autotask
- Run is marked as **Resolved**
- Resolution origin:
- `Resolved by Backupchecks`
#### Case B Time entries exist
- Ticket is not closed
- Fixed-format internal system note is added
- Run remains unresolved
### 7.3 Control moment Phase 2.4
Functional validation:
- Resolution works only under defined rules
- Time entry logic is respected
- Resolution origin is persisted correctly
---
### 6.2 Resolution flow
1. Operator triggers **Resolve ticket**
2. Backupchecks retrieves ticket details from Autotask
3. Time entry check is executed
#### Case A No time entries
- Ticket is set to the configured completed status in Autotask
- Run is marked as **Resolved**
- Resolution origin:
- `Resolved by Backupchecks`
#### Case B Time entries exist
- Ticket is not closed
- A fixed-format internal system note is added
- Run remains unresolved
---
### 6.3 Post-resolution sync alignment
If a ticket resolved by Backupchecks is later polled as Completed:
- Backupchecks keeps the existing resolved state
- Resolution origin remains:
- `Resolved by Backupchecks`
If Autotask resolves the ticket first:
- Backupchecks sets:
- `Resolved by PSA`
---
## 7. Data Model Adjustments
Phase 2 requires the following additional fields per run:
- `ticket_last_polled_at`
- `ticket_resolution_origin`:
- `psa`
- `backupchecks`
- `ticket_deleted_in_psa` (boolean)
Existing Phase 1 fields remain unchanged.
---
## 8. UI Behaviour
### 8.1 Run Checks
- Visual indicator for linked tickets
- Resolution origin badge:
- Resolved by PSA
- Resolved by Backupchecks
- Warning banner for deleted tickets
---
### 8.2 Job Details
- Same indicators as Run Checks
- Resolve action visibility follows resolution rules
---
## 9. Error Handling & Logging
- All Autotask API calls are logged with:
- Correlation ID
- Ticket ID
- Run ID
- Polling failures do not block page rendering
- Partial polling failures are tolerated per ticket
---
## 10. Explicit Non-Implementation (Phase 2)
The following are **not implemented**:
- Background schedulers
- Automatic ticket creation
- Automatic ticket reopening
- Status pushing without operator action
- Ticket content mutation beyond fixed system notes
---
## 11. Phase-based Implementation Workflow
For each Phase 2 sub-phase, a **dedicated chat** must be used. This ensures focus, traceability, and controlled implementation.
The instructions below must be copied **verbatim** when starting a new chat for the corresponding phase.
---
## Phase 2.1 Chat Instructions
Purpose:
Implement **read-only ticket polling** when the Run Checks page is opened.
Chat instructions:
- Scope is limited to Phase 2.1 only
- No ticket state changes are allowed
- No resolve, close, or mutation logic may be added
- Only backend polling and UI visibility updates are permitted
- All changes must be functionally testable on Run Checks
Exit criteria:
- Polling executes only when Run Checks is opened
- Only relevant active tickets are queried
- No Backupchecks run state is modified
---
## Phase 2.2 Chat Instructions
Purpose:
Implement **PSA-driven resolution handling** based on polling results.
Chat instructions:
- Phase 2.1 must already be completed and validated
- Only Completed ticket handling may be added
- Resolution origin must be stored and displayed
- No Backupchecks-initiated ticket changes are allowed
Exit criteria:
- Completed tickets resolve runs correctly
- Resolution origin is accurate and persistent
- Active tickets remain unchanged
---
## Phase 2.3 Chat Instructions
Purpose:
Implement **deleted ticket detection and operator visibility**.
Chat instructions:
- Phase 2.1 and 2.2 must already be completed
- Detection must be explicit and non-destructive
- Historical ticket references must remain intact
- UI must clearly indicate deleted state
Exit criteria:
- Deleted tickets are reliably detected
- Operators are clearly informed
- Ticket actions are blocked correctly
---
## Phase 2.4 Chat Instructions
Purpose:
Implement **manual ticket resolution from Backupchecks to Autotask**.
Chat instructions:
- All previous Phase 2 sub-phases must be completed
- Resolution must be strictly operator-driven
- Time entry rules must be enforced exactly
- Resolution origin must be stored correctly
Exit criteria:
- Tickets are resolved only when allowed
- Time entry edge cases behave correctly
- State remains consistent with PSA polling
---
## 12. Phase 2 Completion Criteria
Phase 2 is considered complete when:
- All Phase 2 sub-phases have passed their control moments
- No cross-phase leakage of logic exists
- Enable/disable integration behaviour is respected
- Functional behaviour matches the approved design exactly
---
End of Phase 2 implementation document.

View File

@ -1,3 +1,227 @@
## v20260115-01-autotask-settings
### Changes:
- Added initial Autotask integration settings structure to Backupchecks.
- Introduced new system settings demonstrating Autotask configuration fields such as enable toggle, environment selection, credentials, tracking identifier, and Backupchecks base URL.
- Prepared data model and persistence layer to store Autotask-related configuration.
- Laid groundwork for future validation and integration logic without enabling ticket creation or customer mapping.
- Ensured changes are limited to configuration foundations only, keeping Phase 1 scope intact.
## v20260115-02-autotask-settings-migration-fix
### Changes:
- Fixed Autotask system settings migration so it is always executed during application startup.
- Added safe, idempotent column existence checks to prevent startup failures on re-deployments.
- Ensured all Autotask-related system_settings columns are created before being queried.
- Prevented aborted database transactions caused by missing columns during settings initialization.
- Improved overall stability of the Settings page when Autotask integration is enabled.
## v20260115-03-autotask-settings-ui
### Changes:
- Added visible Autotask configuration section under Settings → Integrations.
- Implemented form fields for enabling Autotask integration, environment selection, API credentials, tracking identifier, and Backupchecks base URL.
- Wired Autotask settings to SystemSettings for loading and saving configuration values.
- Added Diagnostics & Reference Data section with actions for testing the Autotask connection and refreshing reference data.
- Kept all functionality strictly within Phase 1 scope without introducing ticket or customer logic.
## v20260115-04-autotask-reference-data-fix
### Changes:
- Fixed Autotask API client to use correct endpoints for reference data instead of invalid `/query` routes.
- Implemented proper retrieval of Autotask Queues and Ticket Sources via collection endpoints.
- Added dynamic retrieval of Autotask Priorities using ticket entity metadata and picklist values.
- Cached queues, ticket sources, and priorities in system settings for safe reuse in the UI.
- Updated Autotask settings UI to use dropdowns backed by live Autotask reference data.
- Improved “Test connection” to validate authentication and reference data access reliably.
- Fixed admin event logging to prevent secondary exceptions during error handling.
## v20260115-05-autotask-queues-picklist-fix
Changes:
- Reworked Autotask reference data retrieval to use Ticket entity picklists instead of non-existent top-level resources.
- Retrieved Queues via the Tickets.queueID picklist to ensure compatibility with all Autotask tenants.
- Retrieved Ticket Sources via the Tickets.source picklist instead of a direct collection endpoint.
- Kept Priority retrieval fully dynamic using the Tickets.priority picklist.
- Normalized picklist values so IDs and display labels are handled consistently in settings dropdowns.
- Fixed Autotask connection test to rely on picklist availability, preventing false 404 errors.
## v20260115-06-autotask-auth-fallback
### Changes:
- Improved Autotask authentication handling to support sandbox-specific behavior.
- Implemented automatic fallback authentication flow when initial Basic Auth returns HTTP 401.
- Added support for header-based authentication using UserName and Secret headers alongside the Integration Code.
- Extended authentication error diagnostics to include selected environment and resolved Autotask zone information.
- Increased reliability of Autotask connection testing across different tenants and sandbox configurations.
## v20260115-07-autotask-picklist-field-detect
### Changes:
- Improved detection of Autotask Ticket entity picklist fields to handle tenant-specific field naming.
- Added fallback matching logic based on field name and display label for picklist fields.
- Fixed queue picklist resolution when fields are not named exactly `queue` or `queueid`.
- Applied the same robust detection logic to ticket priority picklist retrieval.
- Prevented connection test failures caused by missing or differently named metadata fields.
## v20260115-08-autotask-entityinfo-fields-shape-fix
### Changes:
- Fixed parsing of Autotask entityInformation responses to correctly read field metadata from the `fields` attribute.
- Extended metadata normalization to support different response shapes returned by Autotask.
- Improved picklist value handling to support both inline picklist values and URL-based retrieval.
- Resolved failures in queue, source, and priority picklist detection caused by empty or misparsed field metadata.
- Stabilized Autotask connection testing across sandbox environments with differing metadata formats.
## v20260115-09-autotask-customer-company-mapping
- Added explicit Autotask company mapping to customers using ID-based linkage.
- Extended customer data model with Autotask company ID, cached company name, mapping status, and last sync timestamp.
- Implemented Autotask company search and lookup endpoints for customer mapping.
- Added mapping status handling to detect renamed, missing, or invalid Autotask companies.
- Updated Customers UI to allow searching, selecting, refreshing, and clearing Autotask company mappings.
- Ensured mappings remain stable when Autotask company names change and block future ticket actions when mappings are invalid.
## v20260115-10-autotask-customers-settings-helper-fix
- Fixed /customers crash caused by missing _get_or_create_settings by removing reliance on shared star-imported helpers.
- Added a local SystemSettings get-or-create helper in customers routes to prevent runtime NameError in mixed/partial deployments.
- Added explicit imports for SystemSettings, db, and datetime to keep the Customers page stable across versions.
## v20260115-11-autotask-companyname-unwrap
- Fixed Autotask company name being shown as "Unknown" by correctly unwrapping nested Autotask API responses.
- Improved company lookup handling to support different response shapes (single item and collection wrappers).
- Ensured the cached Autotask company name is stored and displayed consistently after mapping and refresh.
## v20260115-12-autotask-customers-refreshall-mappings
- Added a “Refresh all Autotask mappings” button on the Customers page to validate all mapped customers in one action.
- Implemented a new backend endpoint to refresh mapping status for all customers with an Autotask Company ID and return a status summary (ok/renamed/missing/invalid).
- Updated the Customers UI to call the refresh-all endpoint, show a short result summary, and reload to reflect updated mapping states.
## v20260115-14-autotask-runchecks-ticket-migration-fix
- Fixed missing database helper used by the Autotask ticket fields migration for job runs.
- Corrected the job_runs migration to ensure Autotask ticket columns are created reliably and committed properly.
- Resolved Run Checks errors caused by incomplete database migrations after introducing Autotask ticket support.
## v20260115-15-autotask-default-ticket-status-setting
- Added “Default Ticket Status” dropdown to Autotask settings (Ticket defaults).
- Implemented retrieval and caching of Autotask ticket statuses as reference data for dropdown usage.
- Extended reference data refresh to include Ticket Statuses and updated diagnostics counters accordingly.
- Added database column for cached ticket statuses and included it in migrations for existing installations.
## v20260115-16-autotask-ticket-create-response-fix
- Fixed Autotask ticket creation handling for tenants that return a lightweight or empty POST /Tickets response.
- Added support for extracting the created ticket ID from itemId/id fields and from the Location header.
- Added a follow-up GET /Tickets/{id} to always retrieve the full created ticket object (ensuring ticketNumber/id are available).
## v20260115-17-autotask-ticket-create-trackingid-lookup
- Reworked Autotask ticket creation flow to no longer rely on POST /Tickets response data for returning an ID.
- Added deterministic fallback lookup using Tickets/query filtered by TrackingIdentifier (and CompanyID when available).
- Ensured the created ticket is reliably retrieved via follow-up GET /Tickets/{id} so ticketNumber/id can always be stored.
- Eliminated false-negative ticket creation errors when Autotask returns an empty body and no Location header.
## v20260115-19-autotask-ticket-create-debug-logging
- Added optional verbose Autotask ticket creation logging (guarded by BACKUPCHECKS_AUTOTASK_DEBUG=1).
- Introduced per-request correlation IDs and included them in ticket creation error messages for log tracing.
- Logged POST /Tickets response characteristics (status, headers, body preview) to diagnose tenants returning incomplete create responses.
- Logged fallback Tickets/query lookup payload and result shape to pinpoint why deterministic lookup fails.
## v20260116-01-autotask-ticket-id-normalization
### Changes:
- Normalized Autotask GET /Tickets/{id} API responses by unwrapping the returned "item" object.
- Ensured the ticket data is returned as a flat object so existing logic can reliably read the ticket id.
- Enabled correct retrieval of the Autotask ticketNumber via a follow-up GET after ticket creation.
- Prevented false error messages where ticket creation succeeded but no ticket id was detected.
## v20260116-02-runchecks-autotask-create-refresh
### Changes:
- Fixed a JavaScript error in the Run Checks view where a non-existent renderModal() function was called after creating an Autotask ticket.
- Replaced the renderModal() call with renderRun() to properly refresh the Run Checks modal state.
- Ensured the Autotask ticket status is updated in the UI without throwing a frontend error.
## v20260116-03-autotask-ticket-linking-visibility
### Changes:
- Ensured Autotask tickets created via Run Checks are stored as internal Ticket records instead of only external references.
- Linked created Autotask tickets to the corresponding Job Run so they appear in Tickets/Remarks.
- Added proper ticket association to Job Details, matching the behaviour of manually entered tickets.
- Updated the Run Checks view to show the ticket indicator when an Autotask ticket is linked to a run.
## v20260116-04-runchecks-autotask-ticket-polling
### Changes:
- Added read-only Autotask ticket polling triggered on Run Checks page load
- Introduced backend endpoint to poll only relevant active Autotask tickets linked to visible runs
- Implemented ticket ID deduplication to minimize Autotask API calls
- Ensured polling is best-effort and does not block Run Checks rendering
- Added client support for bulk ticket queries with per-ticket fallback
- Updated Run Checks UI to display polled PSA ticket status without modifying run state
- Explicitly prevented any ticket mutation, resolution, or Backupchecks state changes
## v20260116-05-autotask-ticket-create-link-all-open-runs
### Changes:
- Fixed Autotask ticket creation to link the newly created ticket to all relevant open runs of the same job
- Aligned automatic ticket creation behaviour with existing manual ticket linking logic
- Ensured ticket linkage is applied consistently across runs until the ticket is resolved
- Prevented Phase 2.1 polling from being blocked by incomplete ticket-run associations
- No changes made to polling logic, resolution logic, or PSA state interpretation
## v20260116-06-runchecks-polling-merge-fix
### Changes:
- Restored Phase 2.1 read-only Autotask polling logic after ticket-creation fix overwrote Run Checks routes
- Merged polling endpoint and UI polling trigger with updated ticket-linking behaviour
- Ensured polled PSA ticket status is available again on the Run Checks page
- No changes made to ticket creation logic, resolution handling, or Backupchecks run state
## v20260116-07-autotask-ticket-link-all-runs-ticketjobrun-fix
### Changes:
- Fixed Autotask ticket creation linking so the internal TicketJobRun associations are created for all relevant open runs of the same job
- Ensured ticket numbers and ticket presence are consistently visible per run (Run Checks and Job Details), not only for the selected run
- Made the list of runs to link deterministic by collecting run IDs first, then applying both run field updates and internal ticket linking across that stable set
- No changes made to polling logic or PSA status interpretation
## v20260116-08-autotask-ticket-backfill-ticketjobrun
- Fixed inconsistent ticket linking when creating Autotask tickets from the Run Checks page.
- Ensured that newly created Autotask tickets are linked to all related job runs, not only the selected run.
- Backfilled ticket-to-run associations so tickets appear correctly in the Tickets overview.
- Corrected Job Details visibility so open runs linked to the same ticket now display the ticket number consistently.
- Aligned Run Checks, Tickets, and Job Details views to use the same ticket-jobrun linkage logic.
## v20260116-09-autotask-ticket-propagate-active-runs
- Updated ticket propagation logic so Autotask tickets are linked to all active job runs (non-Reviewed) visible on the Run Checks page.
- Ensured ticket remarks and ticket-jobrun entries are created for each active run, not only the initially selected run.
- Implemented automatic ticket inheritance for newly incoming runs of the same job while the ticket remains unresolved.
- Stopped ticket propagation once the ticket or job is marked as Resolved to prevent incorrect linking to closed incidents.
- Aligned Run Checks, Tickets overview, and Job Details to consistently reflect ticket presence across all active runs.
## v20260116-10-autotask-ticket-sync-internal-ticketjobrun
- Aligned Autotask ticket creation with the legacy manual ticket workflow by creating or updating an internal Ticket record using the Autotask ticket number.
- Ensured a one-to-one mapping between Autotask tickets and internal Backupchecks tickets.
- Linked the internal Ticket to all active (non-Reviewed) job runs by creating or backfilling TicketJobRun relations.
- Restored visibility of Autotask-created tickets in Tickets, Tickets/Remarks, and Job Details pages.
- Implemented idempotent behavior so repeated ticket creation or re-polling does not create duplicate tickets or links.
- Prepared the ticket model for future scenarios where Autotask integration can be disabled and tickets can be managed manually again.
## v20260116-11-autotask-ticket-sync-legacy
- Restored legacy internal ticket workflow for Autotask-created tickets by ensuring internal Ticket records are created when missing.
- Implemented automatic creation and linking of TicketJobRun records for all active job_runs (reviewed_at IS NULL) that already contain Autotask ticket data.
- Ensured 1:1 mapping between an Autotask ticket and a single internal Ticket, identical to manual ticket behavior.
- Added inheritance logic so newly created job_runs automatically link to an existing open internal Ticket until it is resolved.
- Aligned Autotask ticket creation and polling paths with the legacy manual ticket creation flow, without changing any UI behavior.
- Ensured solution works consistently with Autotask integration enabled or disabled by relying exclusively on internal Ticket and TicketJobRun structures.
*** ***