Compare commits
22 Commits
main
...
v20260116-
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a16ff010f | |||
| 748769afc0 | |||
| abb6780744 | |||
| 83a29a7a3c | |||
| 66f5a57fe0 | |||
| 473044bd67 | |||
| afd45cc568 | |||
| 3564bcf62f | |||
| 49fd29a6f2 | |||
| 49f6d41715 | |||
| 186807b098 | |||
| c68b401709 | |||
| 5b9b6f4c38 | |||
| 981d65c274 | |||
| 1a2ca59d16 | |||
| 83d487a206 | |||
| 490ab1ae34 | |||
| 1a64627a4e | |||
| d5fdc9a8d9 | |||
| f6310da575 | |||
| 48e7830957 | |||
| 777a9b4b31 |
@ -1 +1 @@
|
||||
v20260113-08-vspc-object-linking-normalize
|
||||
v20260116-02-runchecks-autotask-create-refresh
|
||||
|
||||
@ -0,0 +1,680 @@
|
||||
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 _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})."
|
||||
)
|
||||
@ -1,11 +1,48 @@
|
||||
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")
|
||||
@login_required
|
||||
@roles_required("admin", "operator", "viewer")
|
||||
def customers():
|
||||
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 = []
|
||||
for c in items:
|
||||
# Count jobs linked to this customer
|
||||
@ -19,6 +56,14 @@ def customers():
|
||||
"name": c.name,
|
||||
"active": bool(c.active),
|
||||
"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",
|
||||
customers=rows,
|
||||
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"])
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
|
||||
@ -4,7 +4,8 @@ import calendar
|
||||
|
||||
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 sqlalchemy import and_, or_, func, text
|
||||
|
||||
@ -35,6 +36,106 @@ from ..models import (
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
# A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot.
|
||||
MISSED_GRACE_WINDOW = timedelta(hours=1)
|
||||
@ -753,6 +854,8 @@ def run_checks_details():
|
||||
"mail": mail_meta,
|
||||
"body_html": body_html,
|
||||
"objects": objects_payload,
|
||||
"autotask_ticket_id": getattr(run, "autotask_ticket_id", None),
|
||||
"autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "",
|
||||
}
|
||||
)
|
||||
|
||||
@ -770,6 +873,175 @@ def run_checks_details():
|
||||
return jsonify({"status": "ok", "job": job_payload, "runs": runs_payload})
|
||||
|
||||
|
||||
@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: if already created, return existing linkage.
|
||||
if getattr(run, "autotask_ticket_id", None):
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"ticket_id": int(run.autotask_ticket_id),
|
||||
"ticket_number": getattr(run, "autotask_ticket_number", None) or "",
|
||||
"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()
|
||||
created = client.create_ticket(payload)
|
||||
except Exception as exc:
|
||||
return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400
|
||||
|
||||
ticket_id = created.get("id") if isinstance(created, dict) else None
|
||||
ticket_number = None
|
||||
if isinstance(created, dict):
|
||||
ticket_number = created.get("ticketNumber") or created.get("number") or created.get("ticket_number")
|
||||
|
||||
if not ticket_id:
|
||||
return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400
|
||||
|
||||
try:
|
||||
run.autotask_ticket_id = int(ticket_id)
|
||||
except Exception:
|
||||
run.autotask_ticket_id = None
|
||||
|
||||
run.autotask_ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None
|
||||
run.autotask_ticket_created_at = datetime.utcnow()
|
||||
run.autotask_ticket_created_by_user_id = current_user.id
|
||||
|
||||
try:
|
||||
db.session.add(run)
|
||||
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": False,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@main_bp.post("/api/run-checks/mark-reviewed")
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
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
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
@main_bp.route("/settings/jobs/delete-all", methods=["POST"])
|
||||
@login_required
|
||||
@ -430,6 +432,61 @@ def settings():
|
||||
if "ui_timezone" in request.form:
|
||||
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
|
||||
if "daily_jobs_start_date" in request.form:
|
||||
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
|
||||
|
||||
has_client_secret = bool(settings.graph_client_secret)
|
||||
has_autotask_password = bool(getattr(settings, "autotask_api_password", None))
|
||||
|
||||
# Common UI timezones (IANA names)
|
||||
tz_options = [
|
||||
@ -595,6 +653,37 @@ def settings():
|
||||
except Exception:
|
||||
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(
|
||||
"main/settings.html",
|
||||
settings=settings,
|
||||
@ -602,10 +691,16 @@ def settings():
|
||||
free_disk_human=free_disk_human,
|
||||
free_disk_warning=free_disk_warning,
|
||||
has_client_secret=has_client_secret,
|
||||
has_autotask_password=has_autotask_password,
|
||||
tz_options=tz_options,
|
||||
users=users,
|
||||
admin_users_count=admin_users_count,
|
||||
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_stats=news_admin_stats,
|
||||
)
|
||||
@ -1172,3 +1267,140 @@ def settings_folders():
|
||||
except Exception:
|
||||
pass
|
||||
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"))
|
||||
|
||||
@ -22,6 +22,27 @@ def _is_column_nullable(table_name: str, column_name: str) -> bool:
|
||||
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:
|
||||
"""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:
|
||||
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:
|
||||
@ -779,6 +878,8 @@ def run_migrations() -> None:
|
||||
migrate_system_settings_auto_import_cutoff_date()
|
||||
migrate_system_settings_daily_jobs_start_date()
|
||||
migrate_system_settings_ui_timezone()
|
||||
migrate_system_settings_autotask_integration()
|
||||
migrate_customers_autotask_company_mapping()
|
||||
migrate_mail_messages_columns()
|
||||
migrate_mail_messages_parse_columns()
|
||||
migrate_mail_messages_approval_columns()
|
||||
@ -797,6 +898,7 @@ def run_migrations() -> None:
|
||||
migrate_overrides_match_columns()
|
||||
migrate_job_runs_review_tracking()
|
||||
migrate_job_runs_override_metadata()
|
||||
migrate_job_runs_autotask_ticket_fields()
|
||||
migrate_jobs_archiving()
|
||||
migrate_news_tables()
|
||||
migrate_reporting_tables()
|
||||
@ -804,6 +906,67 @@ def run_migrations() -> None:
|
||||
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:
|
||||
"""Add archiving columns to jobs if missing.
|
||||
|
||||
|
||||
@ -107,6 +107,28 @@ class SystemSettings(db.Model):
|
||||
# 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")
|
||||
|
||||
|
||||
# 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)
|
||||
updated_at = db.Column(
|
||||
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)
|
||||
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)
|
||||
updated_at = db.Column(
|
||||
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_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)
|
||||
updated_at = db.Column(
|
||||
@ -259,6 +295,8 @@ class JobRun(db.Model):
|
||||
|
||||
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):
|
||||
__tablename__ = "job_run_review_events"
|
||||
|
||||
@ -19,6 +19,11 @@
|
||||
</form>
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
@ -29,6 +34,8 @@
|
||||
<th scope="col">Customer</th>
|
||||
<th scope="col">Active</th>
|
||||
<th scope="col">Number of jobs</th>
|
||||
<th scope="col">Autotask company</th>
|
||||
<th scope="col">Autotask mapping</th>
|
||||
{% if can_manage %}
|
||||
<th scope="col">Actions</th>
|
||||
{% endif %}
|
||||
@ -46,6 +53,7 @@
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if c.job_count > 0 %}
|
||||
{{ c.job_count }}
|
||||
@ -53,6 +61,36 @@
|
||||
<span class="text-danger fw-bold">0</span>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
<td>
|
||||
<button
|
||||
@ -63,6 +101,10 @@
|
||||
data-id="{{ c.id }}"
|
||||
data-name="{{ c.name }}"
|
||||
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
|
||||
</button>
|
||||
@ -82,7 +124,7 @@
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<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.
|
||||
</td>
|
||||
</tr>
|
||||
@ -130,6 +172,36 @@
|
||||
Active
|
||||
</label>
|
||||
</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 & Integrations → Autotask.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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 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");
|
||||
editButtons.forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
@ -165,8 +364,140 @@
|
||||
if (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, "<").replace(/>/g, ">") +
|
||||
" <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>
|
||||
|
||||
@ -214,18 +214,16 @@
|
||||
<div id="rcm_alerts" class="small"></div>
|
||||
<div class="mt-2">
|
||||
<div class="row g-2 align-items-start">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="border rounded p-2">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="fw-semibold">New ticket</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
|
||||
</div>
|
||||
<div class="mt-2 small text-muted" id="rcm_ticket_status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="border rounded p-2">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="fw-semibold">Autotask ticket</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button>
|
||||
</div>
|
||||
<div class="mt-2 small" id="rcm_autotask_info"></div>
|
||||
<div class="mt-2 small text-muted" id="rcm_autotask_status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="border rounded p-2">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
@ -841,56 +839,79 @@ table.addEventListener('change', function (e) {
|
||||
}
|
||||
|
||||
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 tCode = document.getElementById('rcm_ticket_code');
|
||||
var tStatus = document.getElementById('rcm_ticket_status');
|
||||
var rBody = document.getElementById('rcm_remark_body');
|
||||
var rStatus = document.getElementById('rcm_remark_status');
|
||||
|
||||
function clearStatus() {
|
||||
if (tStatus) tStatus.textContent = '';
|
||||
if (atStatus) atStatus.textContent = '';
|
||||
if (rStatus) rStatus.textContent = '';
|
||||
}
|
||||
|
||||
function setDisabled(disabled) {
|
||||
if (btnTicket) btnTicket.disabled = disabled;
|
||||
if (btnAutotask) btnAutotask.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.__rcmClearCreateStatus = clearStatus;
|
||||
|
||||
if (btnTicket) {
|
||||
btnTicket.addEventListener('click', function () {
|
||||
function renderAutotaskInfo(run) {
|
||||
if (!atInfo) return;
|
||||
var num = (run && run.autotask_ticket_number) ? String(run.autotask_ticket_number) : '';
|
||||
if (num) {
|
||||
atInfo.innerHTML = '<div><strong>Ticket:</strong> ' + escapeHtml(num) + '</div>';
|
||||
} else if (run && run.autotask_ticket_id) {
|
||||
atInfo.innerHTML = '<div><strong>Ticket:</strong> created</div>';
|
||||
} else {
|
||||
atInfo.innerHTML = '<div class="text-muted">No Autotask ticket created for this run.</div>';
|
||||
}
|
||||
}
|
||||
window.__rcmRenderAutotaskInfo = renderAutotaskInfo;
|
||||
|
||||
if (btnAutotask) {
|
||||
btnAutotask.addEventListener('click', function () {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
clearStatus();
|
||||
var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : '';
|
||||
if (!ticket_code) {
|
||||
if (tStatus) tStatus.textContent = 'Ticket number is required.';
|
||||
else alert('Ticket number is required.');
|
||||
return;
|
||||
}
|
||||
if (!/^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', {
|
||||
if (atStatus) atStatus.textContent = 'Creating ticket...';
|
||||
btnAutotask.disabled = true;
|
||||
apiJson('/api/run-checks/autotask-ticket', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code})
|
||||
body: JSON.stringify({run_id: currentRunId})
|
||||
})
|
||||
.then(function () {
|
||||
if (tCode) tCode.value = '';
|
||||
if (tStatus) tStatus.textContent = '';
|
||||
loadAlerts(currentRunId);
|
||||
.then(function (j) {
|
||||
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
|
||||
if (atStatus) atStatus.textContent = '';
|
||||
|
||||
// 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) {
|
||||
if (tStatus) tStatus.textContent = e.message || 'Failed.';
|
||||
if (atStatus) atStatus.textContent = e.message || 'Failed.';
|
||||
else alert(e.message || 'Failed.');
|
||||
})
|
||||
.finally(function () {
|
||||
// State will be recalculated by renderRun.
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -956,7 +977,8 @@ if (tStatus) tStatus.textContent = '';
|
||||
|
||||
currentRunId = run.id || null;
|
||||
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) {
|
||||
var _rs = (run.status || '').toString().toLowerCase();
|
||||
var _canOverride = !!currentRunId && !run.missed && (_rs.indexOf('override') === -1) && (_rs.indexOf('success') === -1);
|
||||
@ -1144,9 +1166,10 @@ if (tStatus) tStatus.textContent = '';
|
||||
var dot = run.missed ? "dot-missed" : statusDotClass(run.status);
|
||||
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 ticketMark = run.autotask_ticket_id ? ' <span class="ms-2" title="Autotask ticket created" aria-label="Autotask ticket">🎫</span>' : '';
|
||||
|
||||
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) {
|
||||
ev.preventDefault();
|
||||
renderRun(data, idx);
|
||||
|
||||
@ -20,6 +20,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if section == 'imports' %}active{% endif %}" href="{{ url_for('main.settings', section='imports') }}">Imports</a>
|
||||
</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">
|
||||
<a class="nav-link {% if section == 'maintenance' %}active{% endif %}" href="{{ url_for('main.settings', section='maintenance') }}">Maintenance</a>
|
||||
</li>
|
||||
@ -316,6 +319,163 @@
|
||||
{% 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' %}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-lg-6">
|
||||
|
||||
@ -0,0 +1,464 @@
|
||||
# Backupchecks – Autotask Integration
|
||||
|
||||
## Functional Design – Phase 1
|
||||
|
||||
_Last updated: 2026-01-13_
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||
> **Note:** 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”**
|
||||
|
||||
> **Rationale:** There are too many backup alerts that do not require a ticket. Human judgement remains essential.
|
||||
|
||||
### 4.2 One ticket per run (Key decision)
|
||||
|
||||
- **Exactly one ticket per Run**.
|
||||
- A run can never create multiple tickets.
|
||||
- If a ticket exists:
|
||||
- Creation action is replaced by:
|
||||
- “Open ticket”
|
||||
- (Later) “Add note”
|
||||
|
||||
> **Rationale:** Multiple errors within a run often share the same root cause. This prevents ticket flooding.
|
||||
|
||||
### 4.3 Ticket contents (baseline)
|
||||
|
||||
Minimum ticket fields:
|
||||
- Subject:
|
||||
- `[Backupchecks] <Customer> - <Job> - <Status>`
|
||||
- Description:
|
||||
- Run date/time
|
||||
- Backup type and job
|
||||
- Affected objects (e.g. HV01, USB Disk)
|
||||
- Error / warning messages
|
||||
- Reference to Backupchecks (URL or identifier)
|
||||
|
||||
Optional (configurable later):
|
||||
- Queue
|
||||
- Issue type / category
|
||||
- Priority mapping
|
||||
|
||||
---
|
||||
|
||||
## 5. Ticket State Tracking in Backupchecks
|
||||
|
||||
Per Run, Backupchecks stores:
|
||||
- Autotask Ticket ID
|
||||
- Autotask Ticket Number
|
||||
- Ticket URL (optional)
|
||||
- Created by (operator)
|
||||
- Created at timestamp
|
||||
- Last known ticket status (snapshot)
|
||||
|
||||
This ensures:
|
||||
- No duplicate tickets
|
||||
- Full audit trail
|
||||
n- Clear operator feedback
|
||||
|
||||
---
|
||||
|
||||
## 5A. Ticket Content Composition Rules
|
||||
|
||||
This chapter defines how Backupchecks determines **what content is placed in an Autotask ticket**, with the explicit goal of keeping tickets readable and actionable.
|
||||
|
||||
### 5A.1 Guiding principle
|
||||
|
||||
- A ticket is a **signal**, not a log file.
|
||||
- The ticket must remain readable for the ticket owner.
|
||||
- Full technical details always remain available in Backupchecks.
|
||||
|
||||
### 5A.2 Content hierarchy (deterministic)
|
||||
|
||||
Backupchecks applies the following strict hierarchy when composing ticket content:
|
||||
|
||||
1. **Overall remark** (run-level summary) – if present, this is leading.
|
||||
2. **Object-level messages** – used only when no overall remark exists.
|
||||
|
||||
This hierarchy is fixed and non-configurable in phase 1.
|
||||
|
||||
### 5A.3 Scenario A – Overall remark present
|
||||
|
||||
If an overall remark exists for the run:
|
||||
- The ticket description contains:
|
||||
- The overall remark
|
||||
- Job name, run date/time, and status
|
||||
- Object-level errors are **not listed in full**.
|
||||
- A short informational line is added:
|
||||
- “Multiple objects reported errors. See Backupchecks for full details.”
|
||||
|
||||
> **Rationale:** The overall remark already represents a consolidated summary. Listing many objects would reduce ticket clarity.
|
||||
|
||||
### 5A.4 Scenario B – No overall remark
|
||||
|
||||
If no overall remark exists:
|
||||
- The ticket description includes object-level errors.
|
||||
- Object listings are **explicitly limited**:
|
||||
- A maximum of *N* objects (exact value defined during implementation)
|
||||
- If more objects are present:
|
||||
- “And X additional objects reported similar errors.”
|
||||
|
||||
> **Rationale:** Prevents large, unreadable tickets while still providing concrete examples.
|
||||
|
||||
### 5A.5 Mandatory reference to Backupchecks
|
||||
|
||||
Every ticket created by Backupchecks must include a **direct link to the Job Details page of the originating run**.
|
||||
|
||||
This link is intended as the primary navigation entry point for the ticket owner.
|
||||
|
||||
The ticket description must include:
|
||||
- Job name
|
||||
- Run date/time
|
||||
- A clickable URL pointing to the Job Details page of that run in Backupchecks
|
||||
|
||||
> **Rationale:** The Job Details page provides the most complete and structured context for investigation.
|
||||
|
||||
This ensures:
|
||||
- Full traceability
|
||||
- Fast access to complete technical details
|
||||
|
||||
---
|
||||
|
||||
### 5A.6 Explicit exclusions
|
||||
|
||||
The following content is deliberately excluded from ticket descriptions:
|
||||
- Complete object lists when large
|
||||
- Repeated identical error messages
|
||||
- Raw technical dumps or stack traces
|
||||
|
||||
---
|
||||
|
||||
## 6. Ticket Resolution from Backupchecks
|
||||
|
||||
### 6.1 Resolution policy
|
||||
|
||||
Backupchecks **may resolve** an Autotask ticket **only if**:
|
||||
- The ticket exists
|
||||
- The ticket is not already closed
|
||||
- **No time entries are present on the ticket**
|
||||
|
||||
This rule is **mandatory and non-configurable**.
|
||||
|
||||
> **Rationale:** Prevents financial and operational conflicts inside Autotask.
|
||||
|
||||
### 6.2 Behaviour when time entries exist
|
||||
|
||||
If an operator clicks **Resolve ticket** but the ticket **contains time entries**:
|
||||
- The ticket **must not be closed** by Backupchecks.
|
||||
- Backupchecks **adds an internal system note** to the ticket stating that it was marked as resolved from Backupchecks.
|
||||
- The ticket remains open for the ticket owner to review and close manually.
|
||||
|
||||
Proposed internal system note text:
|
||||
|
||||
> `Ticket marked as resolved in Backupchecks, but not closed automatically because time entries are present.`
|
||||
|
||||
> **Rationale:** Ensures the ticket owner is explicitly informed without violating Autotask process or financial controls.
|
||||
|
||||
### 6.3 Closing note (fixed text)
|
||||
|
||||
When resolving a ticket **and no time entries are present**, Backupchecks always adds the following **internal system note** **before closing**:
|
||||
|
||||
> `Ticket resolved via Backupchecks after verification that the backup issue is no longer present.`
|
||||
|
||||
Characteristics:
|
||||
- Fixed text (no operator editing in phase 1)
|
||||
- **System / internal note** (never customer-facing)
|
||||
- Ensures auditability and traceability
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 6A. Handling Existing Tickets & Compatibility Mode
|
||||
|
||||
### 6A.1 Existing manual ticket numbers
|
||||
|
||||
In the pre-integration workflow, a run may already contain a manually entered ticket number.
|
||||
|
||||
When Autotask integration is **enabled**:
|
||||
- Existing ticket numbers remain visible.
|
||||
- Backupchecks may offer a one-time action:
|
||||
- **“Link existing Autotask ticket”**
|
||||
- This validates the ticket in Autotask and stores the **Autotask Ticket ID**.
|
||||
|
||||
> **Note:** Without an Autotask Ticket ID, Backupchecks must not attempt to resolve a ticket.
|
||||
|
||||
When Autotask integration is **disabled**:
|
||||
- The current/manual workflow applies (manual ticket number entry).
|
||||
|
||||
### 6A.2 Linking existing Autotask tickets
|
||||
|
||||
When integration is enabled, operators can link an existing Autotask ticket to a run:
|
||||
- Search/select a ticket (preferably by ticket number)
|
||||
- Store:
|
||||
- Autotask Ticket ID
|
||||
- Autotask Ticket Number
|
||||
- Ticket URL (optional)
|
||||
|
||||
After linking:
|
||||
- The run behaves like an integration-created ticket for viewing and resolution rules.
|
||||
|
||||
### 6A.3 Compatibility mode (optional setting)
|
||||
|
||||
Optional setting (recommended for transition periods):
|
||||
- **“Allow manual ticket number entry when Autotask is enabled”** (default: OFF)
|
||||
|
||||
Behaviour:
|
||||
- When ON, operators can still manually enter a ticket number even if integration is enabled.
|
||||
- Resolve from Backupchecks is still only possible for tickets that have a validated Autotask Ticket ID.
|
||||
|
||||
> **Rationale:** Provides a safe escape hatch during rollout and migration.
|
||||
|
||||
---
|
||||
|
||||
## 6B. Deleted Tickets in Autotask
|
||||
|
||||
Tickets may be deleted in Autotask. When a ticket referenced by Backupchecks is deleted, the linkage becomes invalid.
|
||||
|
||||
### 6B.1 Detection
|
||||
|
||||
When Backupchecks attempts to fetch the ticket by Autotask Ticket ID:
|
||||
- If Autotask returns “not found” (deleted/missing), Backupchecks marks the linkage as **broken**.
|
||||
|
||||
### 6B.2 Behaviour when a ticket is deleted
|
||||
|
||||
- The run keeps the historical reference (ticket number/ID) for audit purposes.
|
||||
- The ticket state is shown as:
|
||||
- **“Missing in Autotask (deleted)”**
|
||||
- Actions are blocked:
|
||||
- No “Open ticket” (if no valid URL)
|
||||
- No “Resolve ticket”
|
||||
- Operators can choose:
|
||||
- **Re-link to another ticket** (if the ticket was recreated or replaced)
|
||||
- **Create a new Autotask ticket** (creates a new link for that run)
|
||||
|
||||
> **Note:** Backupchecks should never silently remove the stored linkage, to preserve auditability.
|
||||
|
||||
### 6B.3 Optional: periodic validation
|
||||
|
||||
Optionally (later), Backupchecks may periodically validate linked ticket IDs and flag missing tickets.
|
||||
|
||||
---
|
||||
|
||||
## 7. Backupchecks Settings
|
||||
|
||||
### 7.1 New settings section
|
||||
|
||||
**Settings → Extensions & Integrations → Autotask**
|
||||
|
||||
### 7.2 Required settings
|
||||
|
||||
- Enable Autotask integration (on/off)
|
||||
- Environment: Sandbox / Production
|
||||
- API Username
|
||||
- API Password
|
||||
- Tracking Identifier
|
||||
|
||||
### 7.3 Ticket creation defaults (configurable)
|
||||
|
||||
These defaults are applied when Backupchecks creates a new Autotask ticket.
|
||||
|
||||
- Ticket Source (default): **Monitoring Alert**
|
||||
- Default Queue (default): **Helpdesk**
|
||||
- Default Status (default): **New**
|
||||
- Priority mapping:
|
||||
- Warning → **Medium**
|
||||
- Error → **High**
|
||||
|
||||
> **Note:** Issue Type / Category is intentionally **not set** by Backupchecks and will be assigned by the ticket owner or traffic manager.
|
||||
|
||||
---
|
||||
|
||||
### 7.3A Backupchecks Base URL
|
||||
|
||||
- Base URL of the Backupchecks instance (e.g. `https://backupchecks.example.com`)
|
||||
|
||||
This value is required to construct:
|
||||
- Direct links to Job Details pages
|
||||
- Stable references inside Autotask tickets
|
||||
|
||||
> **Note:** This setting is mandatory for ticket creation and must be validated.
|
||||
|
||||
---
|
||||
|
||||
### 7.4 Dynamic reference data
|
||||
|
||||
Backupchecks must retrieve the following reference data from Autotask and present it in Settings:
|
||||
- Available Queues
|
||||
- Available Ticket Sources
|
||||
|
||||
These lists are:
|
||||
- Loaded on demand (or via refresh action)
|
||||
- Stored for selection in Settings
|
||||
|
||||
> **Rationale:** Prevents hard-coded values and keeps Backupchecks aligned with Autotask configuration changes.
|
||||
|
||||
### 7.5 Resolve configuration
|
||||
|
||||
- Allow resolving tickets from Backupchecks (on/off)
|
||||
- Closing note texts (read-only, fixed):
|
||||
- Standard resolve note
|
||||
- Time-entry-blocked resolve note
|
||||
|
||||
### 7.6 Validation & diagnostics
|
||||
|
||||
- Test connection
|
||||
- Validate configuration (credentials, reference data access)
|
||||
- Optional API logging level
|
||||
|
||||
---
|
||||
|
||||
## 8. Roles & Permissions
|
||||
|
||||
- Admin / Operator:
|
||||
- Create tickets
|
||||
- Resolve tickets (if allowed)
|
||||
- Reporter:
|
||||
- View ticket number and link
|
||||
- No create or resolve actions
|
||||
|
||||
---
|
||||
|
||||
## 9. Handling Existing, Linked and Deleted Tickets
|
||||
|
||||
### 9.1 Existing tickets (pre-integration)
|
||||
|
||||
- Runs that already contain a manually entered ticket number remain valid.
|
||||
- When Autotask integration is enabled, operators may optionally:
|
||||
- Link the run to an existing Autotask ticket (validated against Autotask).
|
||||
- After linking, the run follows the same rules as integration-created tickets.
|
||||
|
||||
> **Note:** This optional compatibility flow exists to support a gradual transition and avoids forced migration.
|
||||
|
||||
### 9.2 Optional compatibility mode
|
||||
|
||||
- Optional setting: **Allow manual ticket number entry when Autotask is enabled**
|
||||
- Default: OFF
|
||||
- Intended as a temporary transition mechanism.
|
||||
|
||||
### 9.3 Deleted tickets in Autotask (important case)
|
||||
|
||||
Tickets may be deleted directly in Autotask. Backupchecks must handle this safely and explicitly.
|
||||
|
||||
Behaviour:
|
||||
- Backupchecks never assumes tickets exist based on stored data alone.
|
||||
- On any ticket-related action (view, resolve, open):
|
||||
- Backupchecks validates the ticket ID against Autotask.
|
||||
|
||||
If Autotask returns *not found*:
|
||||
- The ticket is marked as **Deleted (external)**.
|
||||
- The existing link is preserved as historical data but marked inactive.
|
||||
- No further actions (resolve, update) are allowed on that ticket.
|
||||
|
||||
UI behaviour:
|
||||
- Ticket number remains visible with a clear indicator:
|
||||
- “Ticket deleted in Autotask”
|
||||
- Operator is offered one explicit action:
|
||||
- “Create new Autotask ticket” (results in a new ticket linked to the same run)
|
||||
|
||||
> **Rationale:** Ticket deletion is an external administrative decision. Backupchecks records the fact but does not attempt to repair or hide it.
|
||||
|
||||
### 9.4 Why links are not silently removed
|
||||
|
||||
- Silent removal would break audit trails.
|
||||
- Historical runs must retain context, even if external objects no longer exist.
|
||||
- Operators must explicitly decide how to proceed.
|
||||
|
||||
---
|
||||
|
||||
## 10. Explicit Non-Goals (Phase 1)
|
||||
|
||||
The following are explicitly excluded:
|
||||
- Automatic ticket creation
|
||||
- Automatic ticket closing
|
||||
- Automatic re-creation of deleted tickets
|
||||
- Updating ticket content after creation
|
||||
- Multiple tickets per run
|
||||
- Time entry handling
|
||||
- Multi-PSA support
|
||||
|
||||
---
|
||||
|
||||
## 11. Phase 1 Summary
|
||||
|
||||
Phase 1 delivers:
|
||||
- Safe, controlled PSA integration
|
||||
- Operator-driven ticket lifecycle
|
||||
- Explicit handling of legacy, linked and deleted tickets
|
||||
- Clear audit trail
|
||||
- Minimal risk to Autotask data integrity
|
||||
|
||||
This design intentionally prioritises **predictability and control** over automation.
|
||||
|
||||
Future phases may build on this foundation.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -1,3 +1,148 @@
|
||||
## 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.
|
||||
|
||||
***
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user