Compare commits
60 Commits
main
...
v20260120-
| Author | SHA1 | Date | |
|---|---|---|---|
| ae1865dab3 | |||
| 92c67805e5 | |||
| fc0cf1ef96 | |||
| 899863a0de | |||
| e4e069a6b3 | |||
| dfca88d3bd | |||
| 5c0e1b08aa | |||
| 4b506986a6 | |||
| 5131d24751 | |||
| 63526be592 | |||
| b56cdacf6b | |||
| 4b3b6162a0 | |||
| a7a61fdd64 | |||
| 8407bf45ab | |||
| 0cabd2e0fc | |||
| 0c5dee307f | |||
| 0500491621 | |||
| 890553f23e | |||
| c5ff1e11a3 | |||
| c595c165ed | |||
| d272d12d24 | |||
| 2887a021ba | |||
| d5e3734b35 | |||
| 07e6630a89 | |||
| dabec03f91 | |||
| 36deb77806 | |||
| 82bdebb721 | |||
| f8a57efee0 | |||
| 46cc5b10ab | |||
| 4c18365753 | |||
| 4def0aad46 | |||
| 9025d70b8e | |||
| ef8d12065b | |||
| 25d1962f7b | |||
| 487f923064 | |||
| f780bbc399 | |||
| b46b7fbc21 | |||
| 9399082231 | |||
| 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
|
v20260120-09-runchecks-modal-sequence-fix
|
||||||
|
|||||||
@ -0,0 +1,659 @@
|
|||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
@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 _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(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
params: Optional[Dict[str, Any]] = None,
|
||||||
|
json_body: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Any:
|
||||||
|
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):
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 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.")
|
||||||
|
|
||||||
|
data = self._request("POST", "Tickets", json_body=payload)
|
||||||
|
# Autotask commonly returns only an itemId on create.
|
||||||
|
# We normalize that into a dict with an "id" key so callers can
|
||||||
|
# perform a follow-up GET /Tickets/{id} to retrieve the full object
|
||||||
|
# and the human-facing ticketNumber.
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if "item" in data and isinstance(data.get("item"), dict):
|
||||||
|
return data["item"]
|
||||||
|
if "items" in data and isinstance(data.get("items"), list) and data.get("items"):
|
||||||
|
first = data.get("items")[0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
return first
|
||||||
|
# Autotask create responses often look like: {"itemId": 12345}
|
||||||
|
item_id = data.get("itemId")
|
||||||
|
if item_id is None:
|
||||||
|
item_id = data.get("itemID")
|
||||||
|
if item_id is not None:
|
||||||
|
try:
|
||||||
|
tid = int(item_id)
|
||||||
|
except Exception:
|
||||||
|
tid = 0
|
||||||
|
if tid > 0:
|
||||||
|
return {"id": tid}
|
||||||
|
if "id" in data:
|
||||||
|
return data
|
||||||
|
# Fallback: return normalized first item if possible
|
||||||
|
items = self._as_items_list(data)
|
||||||
|
if items:
|
||||||
|
return items[0]
|
||||||
|
|
||||||
|
raise AutotaskError("Autotask did not return a created ticket object.")
|
||||||
|
|
||||||
|
|
||||||
|
def get_ticket(self, ticket_id: int) -> Dict[str, Any]:
|
||||||
|
"""Retrieve a Ticket by Autotask Ticket ID.
|
||||||
|
|
||||||
|
Uses GET /Tickets/{id}.
|
||||||
|
|
||||||
|
This is the authoritative retrieval method and is mandatory after creation,
|
||||||
|
because the create response does not reliably include the human-facing
|
||||||
|
ticket number.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
tid = int(ticket_id)
|
||||||
|
except Exception:
|
||||||
|
raise AutotaskError("Invalid ticket id.")
|
||||||
|
|
||||||
|
if tid <= 0:
|
||||||
|
raise AutotaskError("Invalid ticket id.")
|
||||||
|
|
||||||
|
data = self._request("GET", f"Tickets/{tid}")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if "item" in data and isinstance(data.get("item"), dict):
|
||||||
|
return data["item"]
|
||||||
|
if "items" in data and isinstance(data.get("items"), list) and data.get("items"):
|
||||||
|
first = data.get("items")[0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
return first
|
||||||
|
# Some environments return the raw object
|
||||||
|
if "id" in data or "ticketNumber" in data or "number" in data:
|
||||||
|
return data
|
||||||
|
|
||||||
|
items = self._as_items_list(data)
|
||||||
|
if items:
|
||||||
|
return items[0]
|
||||||
|
|
||||||
|
raise AutotaskError("Autotask did not return a ticket object.")
|
||||||
|
|
||||||
|
|
||||||
|
def get_resource(self, resource_id: int) -> Dict[str, Any]:
|
||||||
|
"""Retrieve a Resource by Autotask Resource ID.
|
||||||
|
|
||||||
|
Uses GET /Resources/{id}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
rid = int(resource_id)
|
||||||
|
except Exception:
|
||||||
|
raise AutotaskError("Invalid resource id.")
|
||||||
|
|
||||||
|
if rid <= 0:
|
||||||
|
raise AutotaskError("Invalid resource id.")
|
||||||
|
|
||||||
|
data = self._request("GET", f"Resources/{rid}")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if "item" in data and isinstance(data.get("item"), dict):
|
||||||
|
return data["item"]
|
||||||
|
if "items" in data and isinstance(data.get("items"), list) and data.get("items"):
|
||||||
|
first = data.get("items")[0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
return first
|
||||||
|
if "id" in data or "firstName" in data or "lastName" in data:
|
||||||
|
return data
|
||||||
|
|
||||||
|
items = self._as_items_list(data)
|
||||||
|
if items:
|
||||||
|
return items[0]
|
||||||
|
|
||||||
|
raise AutotaskError("Autotask did not return a resource object.")
|
||||||
|
|
||||||
|
|
||||||
|
def query_deleted_ticket_logs_by_ticket_ids(self, ticket_ids: List[int]) -> List[Dict[str, Any]]:
|
||||||
|
"""Query DeletedTicketLogs for a set of ticket IDs.
|
||||||
|
|
||||||
|
Uses POST /DeletedTicketLogs/query.
|
||||||
|
|
||||||
|
Returns list items including ticketID, ticketNumber, deletedByResourceID, deletedDateTime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ids: List[int] = []
|
||||||
|
for x in ticket_ids or []:
|
||||||
|
try:
|
||||||
|
v = int(x)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if v > 0:
|
||||||
|
ids.append(v)
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Field name differs across docs/tenants (ticketID vs ticketId).
|
||||||
|
# Autotask query field matching is case-insensitive in most tenants; we use the common ticketID.
|
||||||
|
payload = {
|
||||||
|
"filter": [
|
||||||
|
{"op": "in", "field": "ticketID", "value": ids},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
data = self._request("POST", "DeletedTicketLogs/query", json_body=payload)
|
||||||
|
return self._as_items_list(data)
|
||||||
|
|
||||||
|
def query_tickets_by_ids(
|
||||||
|
self,
|
||||||
|
ticket_ids: List[int],
|
||||||
|
*,
|
||||||
|
exclude_status_ids: Optional[List[int]] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Query Tickets by ID, optionally excluding statuses.
|
||||||
|
|
||||||
|
Uses POST /Tickets/query.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- This endpoint is not authoritative (tickets can be missing).
|
||||||
|
- Call get_ticket(id) as a fallback for missing IDs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ids: List[int] = []
|
||||||
|
for x in ticket_ids or []:
|
||||||
|
try:
|
||||||
|
v = int(x)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if v > 0:
|
||||||
|
ids.append(v)
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
flt: List[Dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"op": "in",
|
||||||
|
"field": "id",
|
||||||
|
"value": ids,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
ex: List[int] = []
|
||||||
|
for x in exclude_status_ids or []:
|
||||||
|
try:
|
||||||
|
v = int(x)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if v > 0:
|
||||||
|
ex.append(v)
|
||||||
|
|
||||||
|
if ex:
|
||||||
|
flt.append(
|
||||||
|
{
|
||||||
|
"op": "notIn",
|
||||||
|
"field": "status",
|
||||||
|
"value": ex,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
data = self._request("POST", "Tickets/query", json_body={"filter": flt})
|
||||||
|
return self._as_items_list(data)
|
||||||
|
|
||||||
|
def query_tickets_for_company(
|
||||||
|
self,
|
||||||
|
company_id: int,
|
||||||
|
*,
|
||||||
|
search: str = "",
|
||||||
|
exclude_status_ids: Optional[List[int]] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Query Tickets for a specific company, optionally searching by ticket number or title.
|
||||||
|
|
||||||
|
Uses POST /Tickets/query.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Autotask query operators vary by tenant; we use common operators (eq, contains).
|
||||||
|
- If the query fails due to operator support, callers should fall back to get_ticket(id).
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cid = int(company_id)
|
||||||
|
except Exception:
|
||||||
|
cid = 0
|
||||||
|
if cid <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
flt: List[Dict[str, Any]] = [
|
||||||
|
{"op": "eq", "field": "companyID", "value": cid},
|
||||||
|
]
|
||||||
|
|
||||||
|
ex: List[int] = []
|
||||||
|
for x in exclude_status_ids or []:
|
||||||
|
try:
|
||||||
|
v = int(x)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if v > 0:
|
||||||
|
ex.append(v)
|
||||||
|
if ex:
|
||||||
|
flt.append({"op": "notIn", "field": "status", "value": ex})
|
||||||
|
|
||||||
|
q = (search or "").strip()
|
||||||
|
if q:
|
||||||
|
# Ticket numbers in Autotask are typically like T20260119.0004
|
||||||
|
if q.upper().startswith("T") and any(ch.isdigit() for ch in q):
|
||||||
|
flt.append({"op": "eq", "field": "ticketNumber", "value": q.strip()})
|
||||||
|
else:
|
||||||
|
# Broad search on title
|
||||||
|
flt.append({"op": "contains", "field": "title", "value": q})
|
||||||
|
|
||||||
|
data = self._request("POST", "Tickets/query", json_body={"filter": flt})
|
||||||
|
items = self._as_items_list(data)
|
||||||
|
|
||||||
|
# Respect limit if tenant returns more.
|
||||||
|
if limit and isinstance(limit, int) and limit > 0:
|
||||||
|
return items[: int(limit)]
|
||||||
|
return items
|
||||||
@ -16,6 +16,7 @@ from .parsers import parse_mail_message
|
|||||||
from .parsers.veeam import extract_vspc_active_alarms_companies
|
from .parsers.veeam import extract_vspc_active_alarms_companies
|
||||||
from .email_utils import normalize_from_address, extract_best_html_from_eml, is_effectively_blank_html
|
from .email_utils import normalize_from_address, extract_best_html_from_eml, is_effectively_blank_html
|
||||||
from .job_matching import find_matching_job
|
from .job_matching import find_matching_job
|
||||||
|
from .ticketing_utils import link_open_internal_tickets_to_run
|
||||||
|
|
||||||
|
|
||||||
GRAPH_TOKEN_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
GRAPH_TOKEN_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||||
@ -334,6 +335,12 @@ def _store_messages(settings: SystemSettings, messages):
|
|||||||
db.session.add(run)
|
db.session.add(run)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
# Legacy behavior: link any open internal tickets (and propagate PSA linkage) to new runs.
|
||||||
|
try:
|
||||||
|
link_open_internal_tickets_to_run(run=run, job=job)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
auto_approved_runs.append((job.customer_id, job.id, run.id, mail.id))
|
auto_approved_runs.append((job.customer_id, job.id, run.id, mail.id))
|
||||||
created_any = True
|
created_any = True
|
||||||
|
|
||||||
@ -384,6 +391,14 @@ def _store_messages(settings: SystemSettings, messages):
|
|||||||
db.session.add(run)
|
db.session.add(run)
|
||||||
db.session.flush() # ensure run.id is available
|
db.session.flush() # ensure run.id is available
|
||||||
|
|
||||||
|
# Legacy behavior: link any open internal tickets (and propagate PSA linkage) to new runs.
|
||||||
|
try:
|
||||||
|
link_open_internal_tickets_to_run(run=run, job=job)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Update mail message to reflect approval
|
# Update mail message to reflect approval
|
||||||
mail.job_id = job.id
|
mail.job_id = job.id
|
||||||
if hasattr(mail, "approved"):
|
if hasattr(mail, "approved"):
|
||||||
|
|||||||
@ -347,6 +347,8 @@ def api_ticket_resolve(ticket_id: int):
|
|||||||
open_scope = TicketScope.query.filter_by(ticket_id=ticket.id, resolved_at=None).first()
|
open_scope = TicketScope.query.filter_by(ticket_id=ticket.id, resolved_at=None).first()
|
||||||
if open_scope is None and ticket.resolved_at is None:
|
if open_scope is None and ticket.resolved_at is None:
|
||||||
ticket.resolved_at = now
|
ticket.resolved_at = now
|
||||||
|
if getattr(ticket, "resolved_origin", None) is None:
|
||||||
|
ticket.resolved_origin = "backupchecks"
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@ -358,6 +360,8 @@ def api_ticket_resolve(ticket_id: int):
|
|||||||
# Global resolve (from central ticket list): resolve ticket and all scopes
|
# Global resolve (from central ticket list): resolve ticket and all scopes
|
||||||
if ticket.resolved_at is None:
|
if ticket.resolved_at is None:
|
||||||
ticket.resolved_at = now
|
ticket.resolved_at = now
|
||||||
|
if getattr(ticket, "resolved_origin", None) is None:
|
||||||
|
ticket.resolved_origin = "backupchecks"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Resolve any still-open scopes
|
# Resolve any still-open scopes
|
||||||
|
|||||||
@ -1,11 +1,48 @@
|
|||||||
from .routes_shared import * # noqa: F401,F403
|
from .routes_shared import * # noqa: F401,F403
|
||||||
|
|
||||||
|
# Explicit imports for robustness across mixed deployments.
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ..database import db
|
||||||
|
from ..models import SystemSettings
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_settings_local():
|
||||||
|
"""Return SystemSettings, creating a default row if missing.
|
||||||
|
|
||||||
|
This module should not depend on star-imported helpers for settings.
|
||||||
|
Mixed deployments (partial container updates) can otherwise raise a
|
||||||
|
NameError on /customers when the shared helper is not present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings = SystemSettings.query.first()
|
||||||
|
if settings is None:
|
||||||
|
settings = SystemSettings(
|
||||||
|
auto_import_enabled=False,
|
||||||
|
auto_import_interval_minutes=15,
|
||||||
|
auto_import_max_items=50,
|
||||||
|
manual_import_batch_size=50,
|
||||||
|
auto_import_cutoff_date=datetime.utcnow().date(),
|
||||||
|
ingest_eml_retention_days=7,
|
||||||
|
)
|
||||||
|
db.session.add(settings)
|
||||||
|
db.session.commit()
|
||||||
|
return settings
|
||||||
|
|
||||||
@main_bp.route("/customers")
|
@main_bp.route("/customers")
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator", "viewer")
|
@roles_required("admin", "operator", "viewer")
|
||||||
def customers():
|
def customers():
|
||||||
items = Customer.query.order_by(Customer.name.asc()).all()
|
items = Customer.query.order_by(Customer.name.asc()).all()
|
||||||
|
|
||||||
|
settings = _get_or_create_settings_local()
|
||||||
|
autotask_enabled = bool(getattr(settings, "autotask_enabled", False))
|
||||||
|
autotask_configured = bool(
|
||||||
|
(getattr(settings, "autotask_api_username", None))
|
||||||
|
and (getattr(settings, "autotask_api_password", None))
|
||||||
|
and (getattr(settings, "autotask_tracking_identifier", None))
|
||||||
|
)
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for c in items:
|
for c in items:
|
||||||
# Count jobs linked to this customer
|
# Count jobs linked to this customer
|
||||||
@ -19,6 +56,14 @@ def customers():
|
|||||||
"name": c.name,
|
"name": c.name,
|
||||||
"active": bool(c.active),
|
"active": bool(c.active),
|
||||||
"job_count": job_count,
|
"job_count": job_count,
|
||||||
|
"autotask_company_id": getattr(c, "autotask_company_id", None),
|
||||||
|
"autotask_company_name": getattr(c, "autotask_company_name", None),
|
||||||
|
"autotask_mapping_status": getattr(c, "autotask_mapping_status", None),
|
||||||
|
"autotask_last_sync_at": (
|
||||||
|
getattr(c, "autotask_last_sync_at", None).isoformat(timespec="seconds")
|
||||||
|
if getattr(c, "autotask_last_sync_at", None)
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -28,9 +73,259 @@ def customers():
|
|||||||
"main/customers.html",
|
"main/customers.html",
|
||||||
customers=rows,
|
customers=rows,
|
||||||
can_manage=can_manage,
|
can_manage=can_manage,
|
||||||
|
autotask_enabled=autotask_enabled,
|
||||||
|
autotask_configured=autotask_configured,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_autotask_client_or_raise():
|
||||||
|
"""Build an AutotaskClient from settings or raise a user-safe exception."""
|
||||||
|
settings = _get_or_create_settings_local()
|
||||||
|
if not bool(getattr(settings, "autotask_enabled", False)):
|
||||||
|
raise RuntimeError("Autotask integration is disabled.")
|
||||||
|
if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier:
|
||||||
|
raise RuntimeError("Autotask settings incomplete.")
|
||||||
|
|
||||||
|
from ..integrations.autotask.client import AutotaskClient
|
||||||
|
|
||||||
|
return AutotaskClient(
|
||||||
|
username=settings.autotask_api_username,
|
||||||
|
password=settings.autotask_api_password,
|
||||||
|
api_integration_code=settings.autotask_tracking_identifier,
|
||||||
|
environment=(settings.autotask_environment or "production"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.get("/api/autotask/companies/search")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator")
|
||||||
|
def api_autotask_companies_search():
|
||||||
|
q = (request.args.get("q") or "").strip()
|
||||||
|
if not q:
|
||||||
|
return jsonify({"status": "ok", "items": []})
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _get_autotask_client_or_raise()
|
||||||
|
items = client.search_companies(q, limit=25)
|
||||||
|
return jsonify({"status": "ok", "items": items})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"status": "error", "message": str(exc) or "Search failed."}), 400
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_company_name(company: dict) -> str:
|
||||||
|
# Autotask REST payload shapes vary between tenants/endpoints.
|
||||||
|
# - Some single-entity GETs return {"item": {...}}
|
||||||
|
# - Some may return {"items": [{...}]}
|
||||||
|
if isinstance(company, dict):
|
||||||
|
item = company.get("item")
|
||||||
|
if isinstance(item, dict):
|
||||||
|
company = item
|
||||||
|
else:
|
||||||
|
items = company.get("items")
|
||||||
|
if isinstance(items, list) and items and isinstance(items[0], dict):
|
||||||
|
company = items[0]
|
||||||
|
|
||||||
|
return str(
|
||||||
|
(company or {}).get("companyName")
|
||||||
|
or (company or {}).get("CompanyName")
|
||||||
|
or (company or {}).get("name")
|
||||||
|
or (company or {}).get("Name")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.get("/api/customers/<int:customer_id>/autotask-mapping")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator", "viewer")
|
||||||
|
def api_customer_autotask_mapping_get(customer_id: int):
|
||||||
|
c = Customer.query.get_or_404(customer_id)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"customer": {
|
||||||
|
"id": c.id,
|
||||||
|
"autotask_company_id": getattr(c, "autotask_company_id", None),
|
||||||
|
"autotask_company_name": getattr(c, "autotask_company_name", None),
|
||||||
|
"autotask_mapping_status": getattr(c, "autotask_mapping_status", None),
|
||||||
|
"autotask_last_sync_at": (
|
||||||
|
getattr(c, "autotask_last_sync_at", None).isoformat(timespec="seconds")
|
||||||
|
if getattr(c, "autotask_last_sync_at", None)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator")
|
||||||
|
def api_customer_autotask_mapping_set(customer_id: int):
|
||||||
|
c = Customer.query.get_or_404(customer_id)
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
company_id = payload.get("company_id")
|
||||||
|
try:
|
||||||
|
company_id_int = int(company_id)
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"status": "error", "message": "Invalid company_id."}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _get_autotask_client_or_raise()
|
||||||
|
company = client.get_company(company_id_int)
|
||||||
|
name = _normalize_company_name(company)
|
||||||
|
|
||||||
|
c.autotask_company_id = company_id_int
|
||||||
|
c.autotask_company_name = name
|
||||||
|
c.autotask_mapping_status = "ok"
|
||||||
|
c.autotask_last_sync_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
except Exception as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"status": "error", "message": str(exc) or "Failed to set mapping."}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping/clear")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator")
|
||||||
|
def api_customer_autotask_mapping_clear(customer_id: int):
|
||||||
|
c = Customer.query.get_or_404(customer_id)
|
||||||
|
try:
|
||||||
|
c.autotask_company_id = None
|
||||||
|
c.autotask_company_name = None
|
||||||
|
c.autotask_mapping_status = None
|
||||||
|
c.autotask_last_sync_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
except Exception as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"status": "error", "message": str(exc) or "Failed to clear mapping."}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping/refresh")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator")
|
||||||
|
def api_customer_autotask_mapping_refresh(customer_id: int):
|
||||||
|
from ..integrations.autotask.client import AutotaskError
|
||||||
|
|
||||||
|
c = Customer.query.get_or_404(customer_id)
|
||||||
|
company_id = getattr(c, "autotask_company_id", None)
|
||||||
|
if not company_id:
|
||||||
|
return jsonify({"status": "ok", "mapping_status": None})
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _get_autotask_client_or_raise()
|
||||||
|
company = client.get_company(int(company_id))
|
||||||
|
name = _normalize_company_name(company)
|
||||||
|
|
||||||
|
prev = (getattr(c, "autotask_company_name", None) or "").strip()
|
||||||
|
if prev and name and prev != name:
|
||||||
|
c.autotask_company_name = name
|
||||||
|
c.autotask_mapping_status = "renamed"
|
||||||
|
else:
|
||||||
|
c.autotask_company_name = name
|
||||||
|
c.autotask_mapping_status = "ok"
|
||||||
|
c.autotask_last_sync_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"status": "ok", "mapping_status": c.autotask_mapping_status, "company_name": c.autotask_company_name})
|
||||||
|
except AutotaskError as exc:
|
||||||
|
try:
|
||||||
|
code = getattr(exc, "status_code", None)
|
||||||
|
except Exception:
|
||||||
|
code = None
|
||||||
|
|
||||||
|
# 404 -> deleted/missing company in Autotask
|
||||||
|
if code == 404:
|
||||||
|
try:
|
||||||
|
c.autotask_mapping_status = "invalid"
|
||||||
|
c.autotask_last_sync_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"status": "ok", "mapping_status": "invalid"})
|
||||||
|
|
||||||
|
# Other errors: keep mapping but mark as missing (temporary/unreachable)
|
||||||
|
try:
|
||||||
|
c.autotask_mapping_status = "missing"
|
||||||
|
c.autotask_last_sync_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"status": "ok", "mapping_status": "missing", "message": str(exc)})
|
||||||
|
except Exception as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"status": "error", "message": str(exc) or "Refresh failed."}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/api/customers/autotask-mapping/refresh-all")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator")
|
||||||
|
def api_customers_autotask_mapping_refresh_all():
|
||||||
|
"""Refresh mapping status for all customers that have an Autotask company ID."""
|
||||||
|
|
||||||
|
from ..integrations.autotask.client import AutotaskError
|
||||||
|
|
||||||
|
customers = Customer.query.filter(Customer.autotask_company_id.isnot(None)).all()
|
||||||
|
if not customers:
|
||||||
|
return jsonify({"status": "ok", "refreshed": 0, "counts": {"ok": 0, "renamed": 0, "missing": 0, "invalid": 0}})
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _get_autotask_client_or_raise()
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"status": "error", "message": str(exc) or "Autotask is not configured."}), 400
|
||||||
|
|
||||||
|
counts = {"ok": 0, "renamed": 0, "missing": 0, "invalid": 0}
|
||||||
|
refreshed = 0
|
||||||
|
|
||||||
|
for c in customers:
|
||||||
|
company_id = getattr(c, "autotask_company_id", None)
|
||||||
|
if not company_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
company = client.get_company(int(company_id))
|
||||||
|
name = _normalize_company_name(company)
|
||||||
|
|
||||||
|
prev = (getattr(c, "autotask_company_name", None) or "").strip()
|
||||||
|
if prev and name and prev != name:
|
||||||
|
c.autotask_company_name = name
|
||||||
|
c.autotask_mapping_status = "renamed"
|
||||||
|
counts["renamed"] += 1
|
||||||
|
else:
|
||||||
|
c.autotask_company_name = name
|
||||||
|
c.autotask_mapping_status = "ok"
|
||||||
|
counts["ok"] += 1
|
||||||
|
c.autotask_last_sync_at = datetime.utcnow()
|
||||||
|
refreshed += 1
|
||||||
|
except AutotaskError as exc:
|
||||||
|
try:
|
||||||
|
code = getattr(exc, "status_code", None)
|
||||||
|
except Exception:
|
||||||
|
code = None
|
||||||
|
|
||||||
|
if code == 404:
|
||||||
|
c.autotask_mapping_status = "invalid"
|
||||||
|
counts["invalid"] += 1
|
||||||
|
else:
|
||||||
|
c.autotask_mapping_status = "missing"
|
||||||
|
counts["missing"] += 1
|
||||||
|
c.autotask_last_sync_at = datetime.utcnow()
|
||||||
|
refreshed += 1
|
||||||
|
except Exception:
|
||||||
|
c.autotask_mapping_status = "missing"
|
||||||
|
c.autotask_last_sync_at = datetime.utcnow()
|
||||||
|
counts["missing"] += 1
|
||||||
|
refreshed += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"status": "ok", "refreshed": refreshed, "counts": counts})
|
||||||
|
except Exception as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"status": "error", "message": str(exc) or "Failed to refresh all mappings."}), 400
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/customers/create", methods=["POST"])
|
@main_bp.route("/customers/create", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator")
|
@roles_required("admin", "operator")
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from .routes_shared import _format_datetime, _log_admin_event, _send_mail_messag
|
|||||||
from ..email_utils import extract_best_html_from_eml, is_effectively_blank_html
|
from ..email_utils import extract_best_html_from_eml, is_effectively_blank_html
|
||||||
from ..parsers.veeam import extract_vspc_active_alarms_companies
|
from ..parsers.veeam import extract_vspc_active_alarms_companies
|
||||||
from ..models import MailObject
|
from ..models import MailObject
|
||||||
|
from ..ticketing_utils import link_open_internal_tickets_to_run
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
@ -294,6 +295,11 @@ def inbox_message_approve(message_id: int):
|
|||||||
if hasattr(run, 'storage_free_percent') and hasattr(msg, 'storage_free_percent'):
|
if hasattr(run, 'storage_free_percent') and hasattr(msg, 'storage_free_percent'):
|
||||||
run.storage_free_percent = msg.storage_free_percent
|
run.storage_free_percent = msg.storage_free_percent
|
||||||
db.session.add(run)
|
db.session.add(run)
|
||||||
|
db.session.flush()
|
||||||
|
try:
|
||||||
|
link_open_internal_tickets_to_run(run=run, job=job)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Update mail message to reflect approval
|
# Update mail message to reflect approval
|
||||||
msg.job_id = job.id
|
msg.job_id = job.id
|
||||||
@ -523,6 +529,7 @@ def inbox_message_approve_vspc_companies(message_id: int):
|
|||||||
|
|
||||||
# De-duplicate: do not create multiple runs for the same (mail_message_id, job_id).
|
# De-duplicate: do not create multiple runs for the same (mail_message_id, job_id).
|
||||||
run = JobRun.query.filter(JobRun.job_id == job.id, JobRun.mail_message_id == msg.id).first()
|
run = JobRun.query.filter(JobRun.job_id == job.id, JobRun.mail_message_id == msg.id).first()
|
||||||
|
created = False
|
||||||
if run:
|
if run:
|
||||||
skipped_existing += 1
|
skipped_existing += 1
|
||||||
else:
|
else:
|
||||||
@ -535,9 +542,17 @@ def inbox_message_approve_vspc_companies(message_id: int):
|
|||||||
)
|
)
|
||||||
if hasattr(run, "remark"):
|
if hasattr(run, "remark"):
|
||||||
run.remark = getattr(msg, "overall_message", None)
|
run.remark = getattr(msg, "overall_message", None)
|
||||||
|
|
||||||
db.session.add(run)
|
db.session.add(run)
|
||||||
|
created = True
|
||||||
|
|
||||||
|
# Ensure we have IDs before linking tickets or persisting objects.
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
try:
|
||||||
|
link_open_internal_tickets_to_run(run=run, job=job)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if created:
|
||||||
created_runs.append(run)
|
created_runs.append(run)
|
||||||
|
|
||||||
# Persist objects for reporting (idempotent upsert; safe to repeat).
|
# Persist objects for reporting (idempotent upsert; safe to repeat).
|
||||||
@ -683,7 +698,12 @@ def inbox_message_approve_vspc_companies(message_id: int):
|
|||||||
if hasattr(run2, "remark"):
|
if hasattr(run2, "remark"):
|
||||||
run2.remark = getattr(other, "overall_message", None)
|
run2.remark = getattr(other, "overall_message", None)
|
||||||
db.session.add(run2)
|
db.session.add(run2)
|
||||||
|
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
try:
|
||||||
|
link_open_internal_tickets_to_run(run=run2, job=job2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Persist objects per company
|
# Persist objects per company
|
||||||
try:
|
try:
|
||||||
@ -1049,7 +1069,12 @@ def inbox_reparse_all():
|
|||||||
run.storage_free_percent = msg.storage_free_percent
|
run.storage_free_percent = msg.storage_free_percent
|
||||||
|
|
||||||
db.session.add(run)
|
db.session.add(run)
|
||||||
|
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
try:
|
||||||
|
link_open_internal_tickets_to_run(run=run, job=job)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
|
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
|
||||||
created_any = True
|
created_any = True
|
||||||
|
|
||||||
@ -1110,6 +1135,10 @@ def inbox_reparse_all():
|
|||||||
|
|
||||||
db.session.add(run)
|
db.session.add(run)
|
||||||
db.session.flush() # ensure run.id is available
|
db.session.flush() # ensure run.id is available
|
||||||
|
try:
|
||||||
|
link_open_internal_tickets_to_run(run=run, job=job)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
|
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
|
||||||
|
|
||||||
msg.job_id = job.id
|
msg.job_id = job.id
|
||||||
@ -1209,6 +1238,10 @@ def inbox_reparse_all():
|
|||||||
|
|
||||||
db.session.add(run)
|
db.session.add(run)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
try:
|
||||||
|
link_open_internal_tickets_to_run(run=run, job=job)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
|
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
|
||||||
|
|
||||||
msg.job_id = job.id
|
msg.job_id = job.id
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,7 @@
|
|||||||
from .routes_shared import * # noqa: F401,F403
|
from .routes_shared import * # noqa: F401,F403
|
||||||
from .routes_shared import _get_database_size_bytes, _get_or_create_settings, _format_bytes, _get_free_disk_bytes, _log_admin_event
|
from .routes_shared import _get_database_size_bytes, _get_or_create_settings, _format_bytes, _get_free_disk_bytes, _log_admin_event
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
@main_bp.route("/settings/jobs/delete-all", methods=["POST"])
|
@main_bp.route("/settings/jobs/delete-all", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@ -405,6 +407,8 @@ def settings():
|
|||||||
section = (request.args.get("section") or "general").strip().lower() or "general"
|
section = (request.args.get("section") or "general").strip().lower() or "general"
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
autotask_form_touched = any(str(k).startswith("autotask_") for k in (request.form or {}).keys())
|
||||||
|
|
||||||
# NOTE: The Settings UI has multiple tabs with separate forms.
|
# NOTE: The Settings UI has multiple tabs with separate forms.
|
||||||
# Only update values that are present in the submitted form, to avoid
|
# Only update values that are present in the submitted form, to avoid
|
||||||
# clearing unrelated settings when saving from another tab.
|
# clearing unrelated settings when saving from another tab.
|
||||||
@ -430,6 +434,61 @@ def settings():
|
|||||||
if "ui_timezone" in request.form:
|
if "ui_timezone" in request.form:
|
||||||
settings.ui_timezone = (request.form.get("ui_timezone") or "").strip() or "Europe/Amsterdam"
|
settings.ui_timezone = (request.form.get("ui_timezone") or "").strip() or "Europe/Amsterdam"
|
||||||
|
|
||||||
|
# Autotask integration
|
||||||
|
if "autotask_enabled" in request.form:
|
||||||
|
settings.autotask_enabled = bool(request.form.get("autotask_enabled"))
|
||||||
|
|
||||||
|
if "autotask_environment" in request.form:
|
||||||
|
env_val = (request.form.get("autotask_environment") or "").strip().lower()
|
||||||
|
if env_val in ("sandbox", "production"):
|
||||||
|
settings.autotask_environment = env_val
|
||||||
|
else:
|
||||||
|
settings.autotask_environment = None
|
||||||
|
|
||||||
|
if "autotask_api_username" in request.form:
|
||||||
|
settings.autotask_api_username = (request.form.get("autotask_api_username") or "").strip() or None
|
||||||
|
|
||||||
|
if "autotask_api_password" in request.form:
|
||||||
|
pw = (request.form.get("autotask_api_password") or "").strip()
|
||||||
|
if pw:
|
||||||
|
settings.autotask_api_password = pw
|
||||||
|
|
||||||
|
if "autotask_tracking_identifier" in request.form:
|
||||||
|
settings.autotask_tracking_identifier = (request.form.get("autotask_tracking_identifier") or "").strip() or None
|
||||||
|
|
||||||
|
if "autotask_base_url" in request.form:
|
||||||
|
settings.autotask_base_url = (request.form.get("autotask_base_url") or "").strip() or None
|
||||||
|
|
||||||
|
if "autotask_default_queue_id" in request.form:
|
||||||
|
try:
|
||||||
|
settings.autotask_default_queue_id = int(request.form.get("autotask_default_queue_id") or 0) or None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if "autotask_default_ticket_source_id" in request.form:
|
||||||
|
try:
|
||||||
|
settings.autotask_default_ticket_source_id = int(request.form.get("autotask_default_ticket_source_id") or 0) or None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if "autotask_default_ticket_status" in request.form:
|
||||||
|
try:
|
||||||
|
settings.autotask_default_ticket_status = int(request.form.get("autotask_default_ticket_status") or 0) or None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if "autotask_priority_warning" in request.form:
|
||||||
|
try:
|
||||||
|
settings.autotask_priority_warning = int(request.form.get("autotask_priority_warning") or 0) or None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if "autotask_priority_error" in request.form:
|
||||||
|
try:
|
||||||
|
settings.autotask_priority_error = int(request.form.get("autotask_priority_error") or 0) or None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
# Daily Jobs
|
# Daily Jobs
|
||||||
if "daily_jobs_start_date" in request.form:
|
if "daily_jobs_start_date" in request.form:
|
||||||
daily_jobs_start_date_str = (request.form.get("daily_jobs_start_date") or "").strip()
|
daily_jobs_start_date_str = (request.form.get("daily_jobs_start_date") or "").strip()
|
||||||
@ -506,6 +565,48 @@ def settings():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Settings have been saved.", "success")
|
flash("Settings have been saved.", "success")
|
||||||
|
|
||||||
|
# Autotask ticket defaults depend on reference data (queues, sources, statuses, priorities).
|
||||||
|
# When the Autotask integration is (re)configured, auto-refresh the cached reference data
|
||||||
|
# once so the dropdowns become usable immediately.
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
autotask_form_touched
|
||||||
|
and bool(getattr(settings, "autotask_enabled", False))
|
||||||
|
and bool(getattr(settings, "autotask_api_username", None))
|
||||||
|
and bool(getattr(settings, "autotask_api_password", None))
|
||||||
|
and bool(getattr(settings, "autotask_tracking_identifier", None))
|
||||||
|
):
|
||||||
|
missing_cache = (
|
||||||
|
not bool(getattr(settings, "autotask_cached_queues_json", None))
|
||||||
|
or not bool(getattr(settings, "autotask_cached_ticket_sources_json", None))
|
||||||
|
or not bool(getattr(settings, "autotask_cached_ticket_statuses_json", None))
|
||||||
|
or not bool(getattr(settings, "autotask_cached_priorities_json", None))
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_cache:
|
||||||
|
queues, sources, statuses, pr_out = _refresh_autotask_reference_data(settings)
|
||||||
|
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_reference_data_auto_refreshed",
|
||||||
|
"Autotask reference data auto-refreshed after settings save.",
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
db.session.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
flash(f"Autotask reference data refresh failed: {exc}", "warning")
|
||||||
|
_log_admin_event(
|
||||||
|
"autotask_reference_data_auto_refresh_failed",
|
||||||
|
"Autotask reference data auto-refresh failed after settings save.",
|
||||||
|
details=json.dumps({"error": str(exc)}),
|
||||||
|
)
|
||||||
|
|
||||||
# If EML storage has been turned off, clear any stored blobs immediately.
|
# If EML storage has been turned off, clear any stored blobs immediately.
|
||||||
try:
|
try:
|
||||||
if getattr(settings, "ingest_eml_retention_days", 7) == 0:
|
if getattr(settings, "ingest_eml_retention_days", 7) == 0:
|
||||||
@ -537,6 +638,7 @@ def settings():
|
|||||||
free_disk_warning = free_disk_bytes < two_gb
|
free_disk_warning = free_disk_bytes < two_gb
|
||||||
|
|
||||||
has_client_secret = bool(settings.graph_client_secret)
|
has_client_secret = bool(settings.graph_client_secret)
|
||||||
|
has_autotask_password = bool(getattr(settings, "autotask_api_password", None))
|
||||||
|
|
||||||
# Common UI timezones (IANA names)
|
# Common UI timezones (IANA names)
|
||||||
tz_options = [
|
tz_options = [
|
||||||
@ -595,6 +697,37 @@ def settings():
|
|||||||
except Exception:
|
except Exception:
|
||||||
admin_users_count = 0
|
admin_users_count = 0
|
||||||
|
|
||||||
|
# Autotask cached reference data for dropdowns
|
||||||
|
autotask_queues = []
|
||||||
|
autotask_ticket_sources = []
|
||||||
|
autotask_priorities = []
|
||||||
|
autotask_ticket_statuses = []
|
||||||
|
autotask_last_sync_at = getattr(settings, "autotask_reference_last_sync_at", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if getattr(settings, "autotask_cached_queues_json", None):
|
||||||
|
autotask_queues = json.loads(settings.autotask_cached_queues_json) or []
|
||||||
|
except Exception:
|
||||||
|
autotask_queues = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if getattr(settings, "autotask_cached_ticket_sources_json", None):
|
||||||
|
autotask_ticket_sources = json.loads(settings.autotask_cached_ticket_sources_json) or []
|
||||||
|
except Exception:
|
||||||
|
autotask_ticket_sources = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if getattr(settings, "autotask_cached_priorities_json", None):
|
||||||
|
autotask_priorities = json.loads(settings.autotask_cached_priorities_json) or []
|
||||||
|
except Exception:
|
||||||
|
autotask_priorities = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if getattr(settings, "autotask_cached_ticket_statuses_json", None):
|
||||||
|
autotask_ticket_statuses = json.loads(settings.autotask_cached_ticket_statuses_json) or []
|
||||||
|
except Exception:
|
||||||
|
autotask_ticket_statuses = []
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"main/settings.html",
|
"main/settings.html",
|
||||||
settings=settings,
|
settings=settings,
|
||||||
@ -602,10 +735,16 @@ def settings():
|
|||||||
free_disk_human=free_disk_human,
|
free_disk_human=free_disk_human,
|
||||||
free_disk_warning=free_disk_warning,
|
free_disk_warning=free_disk_warning,
|
||||||
has_client_secret=has_client_secret,
|
has_client_secret=has_client_secret,
|
||||||
|
has_autotask_password=has_autotask_password,
|
||||||
tz_options=tz_options,
|
tz_options=tz_options,
|
||||||
users=users,
|
users=users,
|
||||||
admin_users_count=admin_users_count,
|
admin_users_count=admin_users_count,
|
||||||
section=section,
|
section=section,
|
||||||
|
autotask_queues=autotask_queues,
|
||||||
|
autotask_ticket_sources=autotask_ticket_sources,
|
||||||
|
autotask_priorities=autotask_priorities,
|
||||||
|
autotask_ticket_statuses=autotask_ticket_statuses,
|
||||||
|
autotask_last_sync_at=autotask_last_sync_at,
|
||||||
news_admin_items=news_admin_items,
|
news_admin_items=news_admin_items,
|
||||||
news_admin_stats=news_admin_stats,
|
news_admin_stats=news_admin_stats,
|
||||||
)
|
)
|
||||||
@ -1172,3 +1311,147 @@ def settings_folders():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return jsonify({"status": "error", "message": str(exc) or "Failed to load folders."}), 500
|
return jsonify({"status": "error", "message": str(exc) or "Failed to load folders."}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/settings/autotask/test-connection", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin")
|
||||||
|
def settings_autotask_test_connection():
|
||||||
|
settings = _get_or_create_settings()
|
||||||
|
|
||||||
|
if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier:
|
||||||
|
flash("Autotask settings incomplete. Provide username, password and tracking identifier first.", "warning")
|
||||||
|
return redirect(url_for("main.settings", section="integrations"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..integrations.autotask.client import AutotaskClient
|
||||||
|
client = AutotaskClient(
|
||||||
|
username=settings.autotask_api_username,
|
||||||
|
password=settings.autotask_api_password,
|
||||||
|
api_integration_code=settings.autotask_tracking_identifier,
|
||||||
|
environment=(settings.autotask_environment or "production"),
|
||||||
|
)
|
||||||
|
zone = client.get_zone_info()
|
||||||
|
# Lightweight authenticated calls to validate credentials and basic API access
|
||||||
|
_ = client.get_queues()
|
||||||
|
_ = client.get_ticket_sources()
|
||||||
|
flash(f"Autotask connection OK. Zone: {zone.zone_name or 'unknown'}.", "success")
|
||||||
|
_log_admin_event(
|
||||||
|
"autotask_test_connection",
|
||||||
|
"Autotask test connection succeeded.",
|
||||||
|
details=json.dumps({"zone": zone.zone_name, "api_url": zone.api_url}),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
flash(f"Autotask connection failed: {exc}", "danger")
|
||||||
|
_log_admin_event(
|
||||||
|
"autotask_test_connection_failed",
|
||||||
|
"Autotask test connection failed.",
|
||||||
|
details=json.dumps({"error": str(exc)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(url_for("main.settings", section="integrations"))
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_autotask_reference_data(settings):
|
||||||
|
"""Refresh and persist Autotask reference data used for ticket default dropdowns."""
|
||||||
|
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()
|
||||||
|
|
||||||
|
return queues, sources, statuses, pr_out
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
queues, sources, statuses, pr_out = _refresh_autotask_reference_data(settings)
|
||||||
|
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,51 @@ def _is_column_nullable(table_name: str, column_name: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists_on_conn(conn, table_name: str, column_name: str) -> bool:
|
||||||
|
"""Return True if the given column exists using the provided connection.
|
||||||
|
|
||||||
|
This helper is useful inside engine.begin() blocks so we can check
|
||||||
|
column existence without creating a new inspector/connection.
|
||||||
|
"""
|
||||||
|
result = conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = :table
|
||||||
|
AND column_name = :column
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"table": table_name, "column": column_name},
|
||||||
|
)
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_table_columns(conn, table_name: str) -> set[str]:
|
||||||
|
"""Return a set of column names for the given table using the provided connection.
|
||||||
|
|
||||||
|
This helper is designed for use inside engine.begin() blocks so that any
|
||||||
|
errors are properly rolled back before the connection is returned to the pool.
|
||||||
|
|
||||||
|
If the table does not exist (or cannot be inspected), an empty set is returned.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = :table
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"table": table_name},
|
||||||
|
)
|
||||||
|
return {row[0] for row in result.fetchall()}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
def migrate_add_username_to_users() -> None:
|
def migrate_add_username_to_users() -> None:
|
||||||
"""Ensure users.username column exists and is NOT NULL and UNIQUE.
|
"""Ensure users.username column exists and is NOT NULL and UNIQUE.
|
||||||
|
|
||||||
@ -127,6 +172,84 @@ def migrate_system_settings_ui_timezone() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[migrations] Failed to migrate system_settings.ui_timezone: {exc}")
|
print(f"[migrations] Failed to migrate system_settings.ui_timezone: {exc}")
|
||||||
|
|
||||||
|
def migrate_system_settings_autotask_integration() -> None:
|
||||||
|
"""Add Autotask integration columns to system_settings if missing."""
|
||||||
|
|
||||||
|
table = "system_settings"
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
("autotask_enabled", "BOOLEAN NOT NULL DEFAULT FALSE"),
|
||||||
|
("autotask_environment", "VARCHAR(32) NULL"),
|
||||||
|
("autotask_api_username", "VARCHAR(255) NULL"),
|
||||||
|
("autotask_api_password", "VARCHAR(255) NULL"),
|
||||||
|
("autotask_tracking_identifier", "VARCHAR(255) NULL"),
|
||||||
|
("autotask_base_url", "VARCHAR(512) NULL"),
|
||||||
|
("autotask_default_queue_id", "INTEGER NULL"),
|
||||||
|
("autotask_default_ticket_source_id", "INTEGER NULL"),
|
||||||
|
("autotask_default_ticket_status", "INTEGER NULL"),
|
||||||
|
("autotask_priority_warning", "INTEGER NULL"),
|
||||||
|
("autotask_priority_error", "INTEGER NULL"),
|
||||||
|
("autotask_cached_queues_json", "TEXT NULL"),
|
||||||
|
("autotask_cached_ticket_sources_json", "TEXT NULL"),
|
||||||
|
("autotask_cached_priorities_json", "TEXT NULL"),
|
||||||
|
("autotask_cached_ticket_statuses_json", "TEXT NULL"),
|
||||||
|
("autotask_reference_last_sync_at", "TIMESTAMP NULL"),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = db.get_engine()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Could not get engine for system_settings autotask migration: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for column, ddl in columns:
|
||||||
|
if _column_exists_on_conn(conn, table, column):
|
||||||
|
continue
|
||||||
|
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN {column} {ddl}'))
|
||||||
|
print("[migrations] migrate_system_settings_autotask_integration completed.")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Failed to migrate system_settings autotask integration columns: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_customers_autotask_company_mapping() -> None:
|
||||||
|
"""Add Autotask company mapping columns to customers if missing.
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
- autotask_company_id (INTEGER NULL)
|
||||||
|
- autotask_company_name (VARCHAR(255) NULL)
|
||||||
|
- autotask_mapping_status (VARCHAR(20) NULL)
|
||||||
|
- autotask_last_sync_at (TIMESTAMP NULL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
table = "customers"
|
||||||
|
columns = [
|
||||||
|
("autotask_company_id", "INTEGER NULL"),
|
||||||
|
("autotask_company_name", "VARCHAR(255) NULL"),
|
||||||
|
("autotask_mapping_status", "VARCHAR(20) NULL"),
|
||||||
|
("autotask_last_sync_at", "TIMESTAMP NULL"),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = db.get_engine()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Could not get engine for customers autotask mapping migration: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for column, ddl in columns:
|
||||||
|
if _column_exists_on_conn(conn, table, column):
|
||||||
|
continue
|
||||||
|
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN {column} {ddl}'))
|
||||||
|
print("[migrations] migrate_customers_autotask_company_mapping completed.")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Failed to migrate customers autotask company mapping columns: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_mail_messages_columns() -> None:
|
def migrate_mail_messages_columns() -> None:
|
||||||
@ -779,6 +902,8 @@ def run_migrations() -> None:
|
|||||||
migrate_system_settings_auto_import_cutoff_date()
|
migrate_system_settings_auto_import_cutoff_date()
|
||||||
migrate_system_settings_daily_jobs_start_date()
|
migrate_system_settings_daily_jobs_start_date()
|
||||||
migrate_system_settings_ui_timezone()
|
migrate_system_settings_ui_timezone()
|
||||||
|
migrate_system_settings_autotask_integration()
|
||||||
|
migrate_customers_autotask_company_mapping()
|
||||||
migrate_mail_messages_columns()
|
migrate_mail_messages_columns()
|
||||||
migrate_mail_messages_parse_columns()
|
migrate_mail_messages_parse_columns()
|
||||||
migrate_mail_messages_approval_columns()
|
migrate_mail_messages_approval_columns()
|
||||||
@ -793,10 +918,14 @@ def run_migrations() -> None:
|
|||||||
migrate_feedback_tables()
|
migrate_feedback_tables()
|
||||||
migrate_feedback_replies_table()
|
migrate_feedback_replies_table()
|
||||||
migrate_tickets_active_from_date()
|
migrate_tickets_active_from_date()
|
||||||
|
migrate_tickets_resolved_origin()
|
||||||
migrate_remarks_active_from_date()
|
migrate_remarks_active_from_date()
|
||||||
migrate_overrides_match_columns()
|
migrate_overrides_match_columns()
|
||||||
migrate_job_runs_review_tracking()
|
migrate_job_runs_review_tracking()
|
||||||
migrate_job_runs_override_metadata()
|
migrate_job_runs_override_metadata()
|
||||||
|
migrate_job_runs_autotask_ticket_fields()
|
||||||
|
migrate_job_runs_autotask_ticket_deleted_fields()
|
||||||
|
migrate_job_runs_autotask_ticket_deleted_by_name_fields()
|
||||||
migrate_jobs_archiving()
|
migrate_jobs_archiving()
|
||||||
migrate_news_tables()
|
migrate_news_tables()
|
||||||
migrate_reporting_tables()
|
migrate_reporting_tables()
|
||||||
@ -804,6 +933,147 @@ def run_migrations() -> None:
|
|||||||
print("[migrations] All migrations completed.")
|
print("[migrations] All migrations completed.")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_job_runs_autotask_ticket_fields() -> None:
|
||||||
|
"""Add Autotask ticket linkage fields to job_runs if missing.
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
- job_runs.autotask_ticket_id (INTEGER NULL)
|
||||||
|
- job_runs.autotask_ticket_number (VARCHAR(64) NULL)
|
||||||
|
- job_runs.autotask_ticket_created_at (TIMESTAMP NULL)
|
||||||
|
- job_runs.autotask_ticket_created_by_user_id (INTEGER NULL, FK users.id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
table = "job_runs"
|
||||||
|
try:
|
||||||
|
engine = db.get_engine()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Could not get engine for job_runs Autotask ticket migration: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
cols = _get_table_columns(conn, table)
|
||||||
|
if not cols:
|
||||||
|
print("[migrations] job_runs table not found; skipping migrate_job_runs_autotask_ticket_fields.")
|
||||||
|
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] migrate_job_runs_autotask_ticket_fields failed (continuing): {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("[migrations] migrate_job_runs_autotask_ticket_fields completed.")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_job_runs_autotask_ticket_deleted_fields() -> None:
|
||||||
|
"""Add Autotask deleted ticket audit fields to job_runs if missing.
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
- job_runs.autotask_ticket_deleted_at (TIMESTAMP NULL)
|
||||||
|
- job_runs.autotask_ticket_deleted_by_resource_id (INTEGER NULL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
table = "job_runs"
|
||||||
|
try:
|
||||||
|
engine = db.get_engine()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Could not get engine for job_runs Autotask ticket deleted fields migration: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
cols = _get_table_columns(conn, table)
|
||||||
|
if not cols:
|
||||||
|
print("[migrations] job_runs table not found; skipping migrate_job_runs_autotask_ticket_deleted_fields.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if "autotask_ticket_deleted_at" not in cols:
|
||||||
|
print("[migrations] Adding job_runs.autotask_ticket_deleted_at column...")
|
||||||
|
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_deleted_at TIMESTAMP'))
|
||||||
|
|
||||||
|
if "autotask_ticket_deleted_by_resource_id" not in cols:
|
||||||
|
print("[migrations] Adding job_runs.autotask_ticket_deleted_by_resource_id column...")
|
||||||
|
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_deleted_by_resource_id INTEGER'))
|
||||||
|
|
||||||
|
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_deleted_by_resource_id ON "job_runs" (autotask_ticket_deleted_by_resource_id)'))
|
||||||
|
|
||||||
|
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_deleted_at ON "job_runs" (autotask_ticket_deleted_at)'))
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] migrate_job_runs_autotask_ticket_deleted_fields failed (continuing): {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("[migrations] migrate_job_runs_autotask_ticket_deleted_fields completed.")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_job_runs_autotask_ticket_deleted_by_name_fields() -> None:
|
||||||
|
"""Add Autotask deleted-by name audit fields to job_runs if missing.
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
- job_runs.autotask_ticket_deleted_by_first_name (VARCHAR(255) NULL)
|
||||||
|
- job_runs.autotask_ticket_deleted_by_last_name (VARCHAR(255) NULL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
table = "job_runs"
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = db.get_engine()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Could not get engine for job_runs Autotask deleted-by name fields migration: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
cols = _get_table_columns(conn, table)
|
||||||
|
if not cols:
|
||||||
|
print("[migrations] job_runs table not found; skipping migrate_job_runs_autotask_ticket_deleted_by_name_fields.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if "autotask_ticket_deleted_by_first_name" not in cols:
|
||||||
|
print("[migrations] Adding job_runs.autotask_ticket_deleted_by_first_name column...")
|
||||||
|
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_deleted_by_first_name VARCHAR(255)'))
|
||||||
|
|
||||||
|
if "autotask_ticket_deleted_by_last_name" not in cols:
|
||||||
|
print("[migrations] Adding job_runs.autotask_ticket_deleted_by_last_name column...")
|
||||||
|
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_deleted_by_last_name VARCHAR(255)'))
|
||||||
|
|
||||||
|
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_deleted_by_first_name ON "job_runs" (autotask_ticket_deleted_by_first_name)'))
|
||||||
|
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_deleted_by_last_name ON "job_runs" (autotask_ticket_deleted_by_last_name)'))
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] migrate_job_runs_autotask_ticket_deleted_by_name_fields failed (continuing): {exc}")
|
||||||
|
|
||||||
|
print("[migrations] migrate_job_runs_autotask_ticket_deleted_by_name_fields completed.")
|
||||||
|
|
||||||
|
|
||||||
def migrate_jobs_archiving() -> None:
|
def migrate_jobs_archiving() -> None:
|
||||||
"""Add archiving columns to jobs if missing.
|
"""Add archiving columns to jobs if missing.
|
||||||
|
|
||||||
@ -1090,6 +1360,34 @@ def migrate_tickets_active_from_date() -> None:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_tickets_resolved_origin() -> None:
|
||||||
|
"""Add tickets.resolved_origin column if missing.
|
||||||
|
|
||||||
|
Used to show whether a ticket was resolved by PSA polling or manually inside Backupchecks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
table = "tickets"
|
||||||
|
try:
|
||||||
|
engine = db.get_engine()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Could not get engine for tickets resolved_origin migration: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
cols = _get_table_columns(conn, table)
|
||||||
|
if not cols:
|
||||||
|
print("[migrations] tickets table not found; skipping migrate_tickets_resolved_origin.")
|
||||||
|
return
|
||||||
|
if "resolved_origin" not in cols:
|
||||||
|
print("[migrations] Adding tickets.resolved_origin column...")
|
||||||
|
conn.execute(text('ALTER TABLE "tickets" ADD COLUMN resolved_origin VARCHAR(32)'))
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] tickets resolved_origin migration failed (continuing): {exc}")
|
||||||
|
|
||||||
|
print("[migrations] migrate_tickets_resolved_origin completed.")
|
||||||
|
|
||||||
def migrate_mail_messages_overall_message() -> None:
|
def migrate_mail_messages_overall_message() -> None:
|
||||||
"""Add overall_message column to mail_messages if missing."""
|
"""Add overall_message column to mail_messages if missing."""
|
||||||
table = "mail_messages"
|
table = "mail_messages"
|
||||||
|
|||||||
@ -107,6 +107,28 @@ class SystemSettings(db.Model):
|
|||||||
# UI display timezone (IANA name). Used for rendering times in the web interface.
|
# UI display timezone (IANA name). Used for rendering times in the web interface.
|
||||||
ui_timezone = db.Column(db.String(64), nullable=False, default="Europe/Amsterdam")
|
ui_timezone = db.Column(db.String(64), nullable=False, default="Europe/Amsterdam")
|
||||||
|
|
||||||
|
|
||||||
|
# Autotask integration settings
|
||||||
|
autotask_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
autotask_environment = db.Column(db.String(32), nullable=True) # sandbox | production
|
||||||
|
autotask_api_username = db.Column(db.String(255), nullable=True)
|
||||||
|
autotask_api_password = db.Column(db.String(255), nullable=True)
|
||||||
|
autotask_tracking_identifier = db.Column(db.String(255), nullable=True)
|
||||||
|
autotask_base_url = db.Column(db.String(512), nullable=True) # Backupchecks base URL for deep links
|
||||||
|
|
||||||
|
# Autotask defaults (IDs are leading)
|
||||||
|
autotask_default_queue_id = db.Column(db.Integer, nullable=True)
|
||||||
|
autotask_default_ticket_source_id = db.Column(db.Integer, nullable=True)
|
||||||
|
autotask_default_ticket_status = db.Column(db.Integer, nullable=True)
|
||||||
|
autotask_priority_warning = db.Column(db.Integer, nullable=True)
|
||||||
|
autotask_priority_error = db.Column(db.Integer, nullable=True)
|
||||||
|
|
||||||
|
# Cached reference data (for dropdowns)
|
||||||
|
autotask_cached_queues_json = db.Column(db.Text, nullable=True)
|
||||||
|
autotask_cached_ticket_sources_json = db.Column(db.Text, nullable=True)
|
||||||
|
autotask_cached_priorities_json = db.Column(db.Text, nullable=True)
|
||||||
|
autotask_cached_ticket_statuses_json = db.Column(db.Text, nullable=True)
|
||||||
|
autotask_reference_last_sync_at = db.Column(db.DateTime, nullable=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at = db.Column(
|
updated_at = db.Column(
|
||||||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||||
@ -132,6 +154,14 @@ class Customer(db.Model):
|
|||||||
name = db.Column(db.String(255), unique=True, nullable=False)
|
name = db.Column(db.String(255), unique=True, nullable=False)
|
||||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# Autotask company mapping (Phase 3)
|
||||||
|
# Company ID is leading; name is cached for UI display.
|
||||||
|
autotask_company_id = db.Column(db.Integer, nullable=True)
|
||||||
|
autotask_company_name = db.Column(db.String(255), nullable=True)
|
||||||
|
# Mapping status: ok | renamed | missing | invalid
|
||||||
|
autotask_mapping_status = db.Column(db.String(20), nullable=True)
|
||||||
|
autotask_last_sync_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at = db.Column(
|
updated_at = db.Column(
|
||||||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||||
@ -246,6 +276,17 @@ class JobRun(db.Model):
|
|||||||
reviewed_at = db.Column(db.DateTime, nullable=True)
|
reviewed_at = db.Column(db.DateTime, nullable=True)
|
||||||
reviewed_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
reviewed_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
# Autotask integration (Phase 4: ticket creation from Run Checks)
|
||||||
|
autotask_ticket_id = db.Column(db.Integer, nullable=True)
|
||||||
|
autotask_ticket_number = db.Column(db.String(64), nullable=True)
|
||||||
|
autotask_ticket_created_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
autotask_ticket_created_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
autotask_ticket_deleted_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
autotask_ticket_deleted_by_resource_id = db.Column(db.Integer, nullable=True)
|
||||||
|
autotask_ticket_deleted_by_first_name = db.Column(db.String(255), nullable=True)
|
||||||
|
autotask_ticket_deleted_by_last_name = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at = db.Column(
|
updated_at = db.Column(
|
||||||
@ -259,6 +300,8 @@ class JobRun(db.Model):
|
|||||||
|
|
||||||
reviewed_by = db.relationship("User", foreign_keys=[reviewed_by_user_id])
|
reviewed_by = db.relationship("User", foreign_keys=[reviewed_by_user_id])
|
||||||
|
|
||||||
|
autotask_ticket_created_by = db.relationship("User", foreign_keys=[autotask_ticket_created_by_user_id])
|
||||||
|
|
||||||
|
|
||||||
class JobRunReviewEvent(db.Model):
|
class JobRunReviewEvent(db.Model):
|
||||||
__tablename__ = "job_run_review_events"
|
__tablename__ = "job_run_review_events"
|
||||||
@ -383,6 +426,8 @@ class Ticket(db.Model):
|
|||||||
# Audit timestamp: when the ticket was created (UTC, naive)
|
# Audit timestamp: when the ticket was created (UTC, naive)
|
||||||
start_date = db.Column(db.DateTime, nullable=False)
|
start_date = db.Column(db.DateTime, nullable=False)
|
||||||
resolved_at = db.Column(db.DateTime)
|
resolved_at = db.Column(db.DateTime)
|
||||||
|
# Resolution origin for audit/UI: psa | backupchecks
|
||||||
|
resolved_origin = db.Column(db.String(32))
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
|||||||
235
containers/backupchecks/src/backend/app/ticketing_utils.py
Normal file
235
containers/backupchecks/src/backend/app/ticketing_utils.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, date, timezone
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from .database import db
|
||||||
|
from .models import Job, JobRun, SystemSettings, Ticket, TicketJobRun, TicketScope
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ui_timezone_name() -> str:
|
||||||
|
"""Return the configured UI timezone name (IANA), with a safe fallback.
|
||||||
|
|
||||||
|
NOTE: This must not import from any routes_* modules to avoid circular imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = SystemSettings.query.first()
|
||||||
|
name = (getattr(settings, "ui_timezone", None) or "").strip()
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
return (current_app.config.get("TIMEZONE") or "Europe/Amsterdam").strip()
|
||||||
|
except Exception:
|
||||||
|
return "Europe/Amsterdam"
|
||||||
|
|
||||||
|
|
||||||
|
def _to_ui_date(dt_utc_naive: datetime | None) -> date | None:
|
||||||
|
"""Convert a naive UTC datetime to a UI-local date."""
|
||||||
|
if not dt_utc_naive:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(_get_ui_timezone_name())
|
||||||
|
except Exception:
|
||||||
|
tz = None
|
||||||
|
|
||||||
|
if not tz:
|
||||||
|
return dt_utc_naive.date()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if dt_utc_naive.tzinfo is None:
|
||||||
|
dt_utc = dt_utc_naive.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
dt_utc = dt_utc_naive.astimezone(timezone.utc)
|
||||||
|
return dt_utc.astimezone(tz).date()
|
||||||
|
except Exception:
|
||||||
|
return dt_utc_naive.date()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_internal_ticket_for_job(
|
||||||
|
*,
|
||||||
|
ticket_code: str,
|
||||||
|
title: Optional[str],
|
||||||
|
description: str,
|
||||||
|
job: Job,
|
||||||
|
active_from_dt: Optional[datetime],
|
||||||
|
start_dt: Optional[datetime] = None,
|
||||||
|
) -> Ticket:
|
||||||
|
"""Create/reuse an internal Ticket and ensure a job scope exists.
|
||||||
|
|
||||||
|
This mirrors the legacy manual ticket workflow but allows arbitrary ticket codes
|
||||||
|
(e.g. Autotask ticket numbers).
|
||||||
|
"""
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
start_dt = start_dt or now
|
||||||
|
|
||||||
|
code = (ticket_code or "").strip().upper()
|
||||||
|
if not code:
|
||||||
|
raise ValueError("ticket_code is required")
|
||||||
|
|
||||||
|
ticket = Ticket.query.filter_by(ticket_code=code).first()
|
||||||
|
if not ticket:
|
||||||
|
ticket = Ticket(
|
||||||
|
ticket_code=code,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
active_from_date=_to_ui_date(active_from_dt) or _to_ui_date(start_dt) or start_dt.date(),
|
||||||
|
start_date=start_dt,
|
||||||
|
resolved_at=None,
|
||||||
|
)
|
||||||
|
db.session.add(ticket)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Ensure an open job scope exists
|
||||||
|
scope = TicketScope.query.filter_by(ticket_id=ticket.id, scope_type="job", job_id=job.id).first()
|
||||||
|
if not scope:
|
||||||
|
scope = TicketScope(
|
||||||
|
ticket_id=ticket.id,
|
||||||
|
scope_type="job",
|
||||||
|
customer_id=job.customer_id,
|
||||||
|
backup_software=job.backup_software,
|
||||||
|
backup_type=job.backup_type,
|
||||||
|
job_id=job.id,
|
||||||
|
job_name_match=job.job_name,
|
||||||
|
job_name_match_mode="exact",
|
||||||
|
resolved_at=None,
|
||||||
|
)
|
||||||
|
db.session.add(scope)
|
||||||
|
else:
|
||||||
|
# Re-open and refresh scope metadata (legacy behavior)
|
||||||
|
scope.resolved_at = None
|
||||||
|
scope.customer_id = job.customer_id
|
||||||
|
scope.backup_software = job.backup_software
|
||||||
|
scope.backup_type = job.backup_type
|
||||||
|
scope.job_name_match = job.job_name
|
||||||
|
scope.job_name_match_mode = "exact"
|
||||||
|
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_ticket_jobrun_links(
|
||||||
|
*,
|
||||||
|
ticket_id: int,
|
||||||
|
run_ids: Iterable[int],
|
||||||
|
link_source: str,
|
||||||
|
) -> None:
|
||||||
|
"""Idempotently ensure TicketJobRun links exist for all provided run IDs."""
|
||||||
|
|
||||||
|
run_ids_list = [int(x) for x in (run_ids or []) if x is not None]
|
||||||
|
if not run_ids_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
existing = set()
|
||||||
|
try:
|
||||||
|
rows = (
|
||||||
|
db.session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT job_run_id
|
||||||
|
FROM ticket_job_runs
|
||||||
|
WHERE ticket_id = :ticket_id
|
||||||
|
AND job_run_id = ANY(:run_ids)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"ticket_id": int(ticket_id), "run_ids": run_ids_list},
|
||||||
|
)
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
existing = {int(rid) for (rid,) in rows if rid is not None}
|
||||||
|
except Exception:
|
||||||
|
existing = set()
|
||||||
|
|
||||||
|
for rid in run_ids_list:
|
||||||
|
if rid in existing:
|
||||||
|
continue
|
||||||
|
db.session.add(TicketJobRun(ticket_id=int(ticket_id), job_run_id=int(rid), link_source=link_source))
|
||||||
|
|
||||||
|
|
||||||
|
def link_open_internal_tickets_to_run(*, run: JobRun, job: Job) -> None:
|
||||||
|
"""When a new run is created, link any currently open internal tickets for the job.
|
||||||
|
|
||||||
|
This restores legacy behavior where a ticket stays visible for new runs until resolved.
|
||||||
|
Additionally (best-effort), if the job already has Autotask linkage on previous runs,
|
||||||
|
propagate that to the new run so PSA polling remains consistent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not run or not getattr(run, "id", None) or not job or not getattr(job, "id", None):
|
||||||
|
return
|
||||||
|
|
||||||
|
ui_tz = _get_ui_timezone_name()
|
||||||
|
run_date = _to_ui_date(getattr(run, "run_at", None)) or _to_ui_date(datetime.utcnow())
|
||||||
|
|
||||||
|
# Find open tickets scoped to this job for the run date window.
|
||||||
|
# This matches the logic used by Job Details and Run Checks indicators.
|
||||||
|
rows = []
|
||||||
|
try:
|
||||||
|
rows = (
|
||||||
|
db.session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT t.id, t.ticket_code
|
||||||
|
FROM tickets t
|
||||||
|
JOIN ticket_scopes ts ON ts.ticket_id = t.id
|
||||||
|
WHERE ts.job_id = :job_id
|
||||||
|
AND t.active_from_date <= :run_date
|
||||||
|
AND (
|
||||||
|
COALESCE(ts.resolved_at, t.resolved_at) IS NULL
|
||||||
|
OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
|
||||||
|
)
|
||||||
|
ORDER BY t.start_date DESC, t.id DESC
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"job_id": int(job.id), "run_date": run_date, "ui_tz": ui_tz},
|
||||||
|
)
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Link all open tickets to this run (idempotent)
|
||||||
|
for tid, _code in rows:
|
||||||
|
if not TicketJobRun.query.filter_by(ticket_id=int(tid), job_run_id=int(run.id)).first():
|
||||||
|
db.session.add(TicketJobRun(ticket_id=int(tid), job_run_id=int(run.id), link_source="inherit"))
|
||||||
|
|
||||||
|
# Best-effort: propagate Autotask linkage if present on prior runs for the same ticket code.
|
||||||
|
# This allows new runs to keep the PSA ticket reference without requiring UI changes.
|
||||||
|
try:
|
||||||
|
if getattr(run, "autotask_ticket_id", None):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use the newest ticket code to find a matching prior Autotask-linked run.
|
||||||
|
newest_code = (rows[0][1] or "").strip()
|
||||||
|
if not newest_code:
|
||||||
|
return
|
||||||
|
|
||||||
|
prior = (
|
||||||
|
JobRun.query.filter(JobRun.job_id == job.id)
|
||||||
|
.filter(JobRun.autotask_ticket_id.isnot(None))
|
||||||
|
.filter(JobRun.autotask_ticket_number == newest_code)
|
||||||
|
.order_by(JobRun.id.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if prior and getattr(prior, "autotask_ticket_id", None):
|
||||||
|
run.autotask_ticket_id = prior.autotask_ticket_id
|
||||||
|
run.autotask_ticket_number = prior.autotask_ticket_number
|
||||||
|
run.autotask_ticket_created_at = getattr(prior, "autotask_ticket_created_at", None)
|
||||||
|
run.autotask_ticket_created_by_user_id = getattr(prior, "autotask_ticket_created_by_user_id", None)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
@ -19,6 +19,11 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('main.customers_export') }}">Export CSV</a>
|
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('main.customers_export') }}">Export CSV</a>
|
||||||
|
|
||||||
|
{% if autotask_enabled and autotask_configured %}
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="autotaskRefreshAllMappingsBtn" style="white-space: nowrap;">Refresh all Autotask mappings</button>
|
||||||
|
<span class="small text-muted" id="autotaskRefreshAllMappingsMsg"></span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -29,6 +34,8 @@
|
|||||||
<th scope="col">Customer</th>
|
<th scope="col">Customer</th>
|
||||||
<th scope="col">Active</th>
|
<th scope="col">Active</th>
|
||||||
<th scope="col">Number of jobs</th>
|
<th scope="col">Number of jobs</th>
|
||||||
|
<th scope="col">Autotask company</th>
|
||||||
|
<th scope="col">Autotask mapping</th>
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
<th scope="col">Actions</th>
|
<th scope="col">Actions</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -46,6 +53,7 @@
|
|||||||
<span class="badge bg-secondary">Inactive</span>
|
<span class="badge bg-secondary">Inactive</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
{% if c.job_count > 0 %}
|
{% if c.job_count > 0 %}
|
||||||
{{ c.job_count }}
|
{{ c.job_count }}
|
||||||
@ -53,6 +61,36 @@
|
|||||||
<span class="text-danger fw-bold">0</span>
|
<span class="text-danger fw-bold">0</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% if c.autotask_company_id %}
|
||||||
|
<span class="fw-semibold">{{ c.autotask_company_name or 'Unknown' }}</span>
|
||||||
|
<div class="text-muted small">ID: {{ c.autotask_company_id }}</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Not mapped</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{% set st = (c.autotask_mapping_status or '').lower() %}
|
||||||
|
{% if not c.autotask_company_id %}
|
||||||
|
<span class="badge bg-secondary">Not mapped</span>
|
||||||
|
{% elif st == 'ok' %}
|
||||||
|
<span class="badge bg-success">OK</span>
|
||||||
|
{% elif st == 'renamed' %}
|
||||||
|
<span class="badge bg-warning text-dark">Renamed</span>
|
||||||
|
{% elif st == 'missing' %}
|
||||||
|
<span class="badge bg-warning text-dark">Missing</span>
|
||||||
|
{% elif st == 'invalid' %}
|
||||||
|
<span class="badge bg-danger">Invalid</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Unknown</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if c.autotask_last_sync_at %}
|
||||||
|
<div class="text-muted small">Checked: {{ c.autotask_last_sync_at }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
@ -63,6 +101,10 @@
|
|||||||
data-id="{{ c.id }}"
|
data-id="{{ c.id }}"
|
||||||
data-name="{{ c.name }}"
|
data-name="{{ c.name }}"
|
||||||
data-active="{{ '1' if c.active else '0' }}"
|
data-active="{{ '1' if c.active else '0' }}"
|
||||||
|
data-autotask-company-id="{{ c.autotask_company_id or '' }}"
|
||||||
|
data-autotask-company-name="{{ c.autotask_company_name or '' }}"
|
||||||
|
data-autotask-mapping-status="{{ c.autotask_mapping_status or '' }}"
|
||||||
|
data-autotask-last-sync-at="{{ c.autotask_last_sync_at or '' }}"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@ -82,7 +124,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{% if can_manage %}4{% else %}3{% endif %}" class="text-center text-muted py-3">
|
<td colspan="{% if can_manage %}6{% else %}5{% endif %}" class="text-center text-muted py-3">
|
||||||
No customers found.
|
No customers found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -130,6 +172,36 @@
|
|||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4" />
|
||||||
|
|
||||||
|
<h6 class="mb-2">Autotask mapping</h6>
|
||||||
|
{% if autotask_enabled and autotask_configured %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="small text-muted">Current mapping</div>
|
||||||
|
<div id="autotaskCurrentMapping" class="fw-semibold">Not mapped</div>
|
||||||
|
<div id="autotaskCurrentMappingMeta" class="text-muted small"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group input-group-sm mb-2">
|
||||||
|
<input type="text" class="form-control" id="autotaskCompanySearch" placeholder="Search Autotask companies" autocomplete="off" />
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="autotaskCompanySearchBtn">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="autotaskCompanyResults" class="border rounded p-2" style="max-height: 220px; overflow:auto;"></div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="autotaskSetMappingBtn" disabled>Set mapping</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="autotaskRefreshMappingBtn">Refresh status</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" id="autotaskClearMappingBtn">Clear mapping</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="autotaskMappingMsg" class="small text-muted mt-2"></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted small">
|
||||||
|
Autotask integration is not available. Enable and configure it in Settings → Extensions & Integrations → Autotask.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
@ -152,6 +224,133 @@
|
|||||||
var nameInput = document.getElementById("edit_customer_name");
|
var nameInput = document.getElementById("edit_customer_name");
|
||||||
var activeInput = document.getElementById("edit_customer_active");
|
var activeInput = document.getElementById("edit_customer_active");
|
||||||
|
|
||||||
|
// Top-level refresh-all (only present when integration is enabled/configured)
|
||||||
|
var refreshAllBtn = document.getElementById("autotaskRefreshAllMappingsBtn");
|
||||||
|
var refreshAllMsg = document.getElementById("autotaskRefreshAllMappingsMsg");
|
||||||
|
|
||||||
|
// Autotask mapping UI (only present when integration is enabled/configured)
|
||||||
|
var atCurrent = document.getElementById("autotaskCurrentMapping");
|
||||||
|
var atCurrentMeta = document.getElementById("autotaskCurrentMappingMeta");
|
||||||
|
var atSearchInput = document.getElementById("autotaskCompanySearch");
|
||||||
|
var atSearchBtn = document.getElementById("autotaskCompanySearchBtn");
|
||||||
|
var atResults = document.getElementById("autotaskCompanyResults");
|
||||||
|
var atMsg = document.getElementById("autotaskMappingMsg");
|
||||||
|
var atSetBtn = document.getElementById("autotaskSetMappingBtn");
|
||||||
|
var atRefreshBtn = document.getElementById("autotaskRefreshMappingBtn");
|
||||||
|
var atClearBtn = document.getElementById("autotaskClearMappingBtn");
|
||||||
|
|
||||||
|
var currentCustomerId = null;
|
||||||
|
var selectedCompanyId = null;
|
||||||
|
|
||||||
|
function setRefreshAllMsg(text, isError) {
|
||||||
|
if (!refreshAllMsg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshAllMsg.textContent = text || "";
|
||||||
|
if (isError) {
|
||||||
|
refreshAllMsg.classList.remove("text-muted");
|
||||||
|
refreshAllMsg.classList.add("text-danger");
|
||||||
|
} else {
|
||||||
|
refreshAllMsg.classList.remove("text-danger");
|
||||||
|
refreshAllMsg.classList.add("text-muted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMsg(text, isError) {
|
||||||
|
if (!atMsg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
atMsg.textContent = text || "";
|
||||||
|
if (isError) {
|
||||||
|
atMsg.classList.remove("text-muted");
|
||||||
|
atMsg.classList.add("text-danger");
|
||||||
|
} else {
|
||||||
|
atMsg.classList.remove("text-danger");
|
||||||
|
atMsg.classList.add("text-muted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrentMapping(companyId, companyName, mappingStatus, lastSyncAt) {
|
||||||
|
if (!atCurrent || !atCurrentMeta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!companyId) {
|
||||||
|
atCurrent.textContent = "Not mapped";
|
||||||
|
atCurrentMeta.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
atCurrent.textContent = (companyName || "Unknown") + " (ID: " + companyId + ")";
|
||||||
|
|
||||||
|
var parts = [];
|
||||||
|
if (mappingStatus) {
|
||||||
|
parts.push("Status: " + mappingStatus);
|
||||||
|
}
|
||||||
|
if (lastSyncAt) {
|
||||||
|
parts.push("Checked: " + lastSyncAt);
|
||||||
|
}
|
||||||
|
atCurrentMeta.textContent = parts.join(" • ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
if (!atResults) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
atResults.innerHTML = "<div class=\"text-muted small\">No results.</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedCompanyId(cid) {
|
||||||
|
selectedCompanyId = cid;
|
||||||
|
if (atSetBtn) {
|
||||||
|
atSetBtn.disabled = !selectedCompanyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(url, body) {
|
||||||
|
var resp = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify(body || {}),
|
||||||
|
});
|
||||||
|
var data = null;
|
||||||
|
try {
|
||||||
|
data = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
var msg = (data && data.message) ? data.message : ("Request failed (" + resp.status + ").");
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshAllBtn) {
|
||||||
|
refreshAllBtn.addEventListener("click", async function () {
|
||||||
|
if (!confirm("Refresh mapping status for all mapped customers?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshAllBtn.disabled = true;
|
||||||
|
setRefreshAllMsg("Refreshing...", false);
|
||||||
|
try {
|
||||||
|
var data = await postJson("/api/customers/autotask-mapping/refresh-all", {});
|
||||||
|
var counts = (data && data.counts) ? data.counts : null;
|
||||||
|
if (counts) {
|
||||||
|
setRefreshAllMsg(
|
||||||
|
"Done. OK: " + (counts.ok || 0) + ", Renamed: " + (counts.renamed || 0) + ", Missing: " + (counts.missing || 0) + ", Invalid: " + (counts.invalid || 0) + ".",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setRefreshAllMsg("Done.", false);
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
setRefreshAllMsg(e && e.message ? e.message : "Refresh failed.", true);
|
||||||
|
refreshAllBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var editButtons = document.querySelectorAll(".customer-edit-btn");
|
var editButtons = document.querySelectorAll(".customer-edit-btn");
|
||||||
editButtons.forEach(function (btn) {
|
editButtons.forEach(function (btn) {
|
||||||
btn.addEventListener("click", function () {
|
btn.addEventListener("click", function () {
|
||||||
@ -165,8 +364,140 @@
|
|||||||
if (id) {
|
if (id) {
|
||||||
editForm.action = "{{ url_for('main.customers_edit', customer_id=0) }}".replace("0", id);
|
editForm.action = "{{ url_for('main.customers_edit', customer_id=0) }}".replace("0", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Autotask: seed current mapping from row data attributes
|
||||||
|
currentCustomerId = id || null;
|
||||||
|
if (atResults) {
|
||||||
|
clearResults();
|
||||||
|
}
|
||||||
|
setSelectedCompanyId(null);
|
||||||
|
setMsg("", false);
|
||||||
|
|
||||||
|
if (atCurrent) {
|
||||||
|
var atCompanyId = btn.getAttribute("data-autotask-company-id") || "";
|
||||||
|
var atCompanyName = btn.getAttribute("data-autotask-company-name") || "";
|
||||||
|
var atStatus = btn.getAttribute("data-autotask-mapping-status") || "";
|
||||||
|
var atLast = btn.getAttribute("data-autotask-last-sync-at") || "";
|
||||||
|
renderCurrentMapping(atCompanyId, atCompanyName, atStatus, atLast);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (atSearchBtn && atSearchInput && atResults) {
|
||||||
|
atSearchBtn.addEventListener("click", async function () {
|
||||||
|
var q = (atSearchInput.value || "").trim();
|
||||||
|
if (!q) {
|
||||||
|
setMsg("Enter a search term.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMsg("Searching...", false);
|
||||||
|
setSelectedCompanyId(null);
|
||||||
|
atResults.innerHTML = "<div class=\"text-muted small\">Searching...</div>";
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch("/api/autotask/companies/search?q=" + encodeURIComponent(q), {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
if (!resp.ok || !data || data.status !== "ok") {
|
||||||
|
throw new Error((data && data.message) ? data.message : "Search failed.");
|
||||||
|
}
|
||||||
|
var items = data.items || [];
|
||||||
|
if (!items.length) {
|
||||||
|
atResults.innerHTML = "<div class=\"text-muted small\">No companies found.</div>";
|
||||||
|
setMsg("No companies found.", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = "";
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var cid = it.id;
|
||||||
|
var name = it.companyName || it.name || ("Company #" + cid);
|
||||||
|
var active = (it.isActive === false) ? " (inactive)" : "";
|
||||||
|
html +=
|
||||||
|
"<div class=\"form-check\">" +
|
||||||
|
"<input class=\"form-check-input\" type=\"radio\" name=\"autotaskCompanyPick\" id=\"at_company_" + cid + "\" value=\"" + cid + "\" />" +
|
||||||
|
"<label class=\"form-check-label\" for=\"at_company_" + cid + "\">" +
|
||||||
|
name.replace(/</g, "<").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>
|
</script>
|
||||||
|
|||||||
@ -216,14 +216,24 @@
|
|||||||
<div class="row g-2 align-items-start">
|
<div class="row g-2 align-items-start">
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<div class="border rounded p-2">
|
<div class="border rounded p-2">
|
||||||
|
{% if autotask_enabled %}
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<div class="fw-semibold">Autotask ticket</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="rcm_autotask_link_existing">Link existing</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 small" id="rcm_autotask_info"></div>
|
||||||
|
<div class="mt-2 small text-muted" id="rcm_autotask_status"></div>
|
||||||
|
{% else %}
|
||||||
<div class="fw-semibold">New ticket</div>
|
<div class="fw-semibold">New ticket</div>
|
||||||
|
<div class="d-flex gap-2 mt-1">
|
||||||
|
<input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button>
|
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-1 small text-muted" id="rcm_ticket_status"></div>
|
||||||
<input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
|
{% endif %}
|
||||||
</div>
|
|
||||||
<div class="mt-2 small text-muted" id="rcm_ticket_status"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
@ -285,6 +295,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="modal fade" id="autotaskLinkModal" tabindex="-1" aria-labelledby="autotaskLinkModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="autotaskLinkModalLabel">Link existing Autotask ticket</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<input type="text" class="form-control" id="atl_search" placeholder="Search by ticket number or title" />
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="atl_refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mb-2" id="atl_status"></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 1%;"></th>
|
||||||
|
<th>Ticket</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="atl_tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted" id="atl_empty" style="display:none;">No tickets found.</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var table = document.getElementById('runChecksTable');
|
var table = document.getElementById('runChecksTable');
|
||||||
@ -299,6 +346,8 @@
|
|||||||
var currentRunId = null;
|
var currentRunId = null;
|
||||||
var currentPayload = null;
|
var currentPayload = null;
|
||||||
|
|
||||||
|
var autotaskEnabled = {{ 'true' if autotask_enabled else 'false' }};
|
||||||
|
|
||||||
var btnMarkAllReviewed = document.getElementById('rcm_mark_all_reviewed');
|
var btnMarkAllReviewed = document.getElementById('rcm_mark_all_reviewed');
|
||||||
var btnMarkSuccessOverride = document.getElementById('rcm_mark_success_override');
|
var btnMarkSuccessOverride = document.getElementById('rcm_mark_success_override');
|
||||||
|
|
||||||
@ -841,39 +890,106 @@ table.addEventListener('change', function (e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindInlineCreateForms() {
|
function bindInlineCreateForms() {
|
||||||
|
var btnAutotask = document.getElementById('rcm_autotask_create');
|
||||||
|
var btnAutotaskLink = document.getElementById('rcm_autotask_link_existing');
|
||||||
|
var atInfo = document.getElementById('rcm_autotask_info');
|
||||||
|
var atStatus = document.getElementById('rcm_autotask_status');
|
||||||
|
|
||||||
var btnTicket = document.getElementById('rcm_ticket_save');
|
var btnTicket = document.getElementById('rcm_ticket_save');
|
||||||
var btnRemark = document.getElementById('rcm_remark_save');
|
|
||||||
var tCode = document.getElementById('rcm_ticket_code');
|
var tCode = document.getElementById('rcm_ticket_code');
|
||||||
var tStatus = document.getElementById('rcm_ticket_status');
|
var tStatus = document.getElementById('rcm_ticket_status');
|
||||||
|
|
||||||
|
var btnRemark = document.getElementById('rcm_remark_save');
|
||||||
var rBody = document.getElementById('rcm_remark_body');
|
var rBody = document.getElementById('rcm_remark_body');
|
||||||
var rStatus = document.getElementById('rcm_remark_status');
|
var rStatus = document.getElementById('rcm_remark_status');
|
||||||
|
|
||||||
function clearStatus() {
|
function clearStatus() {
|
||||||
|
if (atStatus) atStatus.textContent = '';
|
||||||
if (tStatus) tStatus.textContent = '';
|
if (tStatus) tStatus.textContent = '';
|
||||||
if (rStatus) rStatus.textContent = '';
|
if (rStatus) rStatus.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDisabled(disabled) {
|
function setDisabled(disabled) {
|
||||||
|
if (btnAutotask) btnAutotask.disabled = disabled;
|
||||||
|
if (btnAutotaskLink) btnAutotaskLink.disabled = disabled;
|
||||||
if (btnTicket) btnTicket.disabled = disabled;
|
if (btnTicket) btnTicket.disabled = disabled;
|
||||||
if (btnRemark) btnRemark.disabled = disabled;
|
|
||||||
if (tCode) tCode.disabled = disabled;
|
if (tCode) tCode.disabled = disabled;
|
||||||
if (rBody) rBody.disabled = disabled;
|
if (btnRemark) btnRemark.disabled = disabled;
|
||||||
|
if (rBody) rBody.disabled = disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__rcmSetCreateDisabled = setDisabled;
|
window.__rcmSetCreateDisabled = setDisabled;
|
||||||
window.__rcmClearCreateStatus = clearStatus;
|
window.__rcmClearCreateStatus = clearStatus;
|
||||||
|
|
||||||
|
function renderAutotaskInfo(run) {
|
||||||
|
if (!atInfo) return;
|
||||||
|
var num = (run && run.autotask_ticket_number) ? String(run.autotask_ticket_number) : '';
|
||||||
|
var isResolved = !!(run && run.autotask_ticket_is_resolved);
|
||||||
|
var origin = (run && run.autotask_ticket_resolved_origin) ? String(run.autotask_ticket_resolved_origin) : '';
|
||||||
|
var isDeleted = !!(run && run.autotask_ticket_is_deleted);
|
||||||
|
var deletedAt = (run && run.autotask_ticket_deleted_at) ? String(run.autotask_ticket_deleted_at) : '';
|
||||||
|
var deletedBy = (run && run.autotask_ticket_deleted_by_resource_id) ? String(run.autotask_ticket_deleted_by_resource_id) : '';
|
||||||
|
var deletedByFirst = (run && run.autotask_ticket_deleted_by_first_name) ? String(run.autotask_ticket_deleted_by_first_name) : '';
|
||||||
|
var deletedByLast = (run && run.autotask_ticket_deleted_by_last_name) ? String(run.autotask_ticket_deleted_by_last_name) : '';
|
||||||
|
|
||||||
|
if (num) {
|
||||||
|
var extra = '';
|
||||||
|
if (isDeleted) {
|
||||||
|
var meta = '';
|
||||||
|
if (deletedAt) meta += '<div class="text-muted">Deleted at: ' + escapeHtml(deletedAt) + '</div>';
|
||||||
|
if (deletedByFirst || deletedByLast) {
|
||||||
|
meta += '<div class="text-muted">Deleted by: ' + escapeHtml((deletedByFirst + ' ' + deletedByLast).trim()) + '</div>';
|
||||||
|
} else if (deletedBy) {
|
||||||
|
meta += '<div class="text-muted">Deleted by resource ID: ' + escapeHtml(deletedBy) + '</div>';
|
||||||
|
}
|
||||||
|
extra = '<div class="mt-1"><span class="badge bg-danger">Deleted in PSA</span></div>' + meta;
|
||||||
|
} else if (isResolved && origin === 'psa') {
|
||||||
|
extra = '<div class="mt-1"><span class="badge bg-secondary">Resolved by PSA</span></div>';
|
||||||
|
}
|
||||||
|
atInfo.innerHTML = '<div><strong>Ticket:</strong> ' + escapeHtml(num) + '</div>' + extra;
|
||||||
|
} else if (run && run.autotask_ticket_id) {
|
||||||
|
atInfo.innerHTML = '<div><strong>Ticket:</strong> created</div>';
|
||||||
|
} else {
|
||||||
|
atInfo.innerHTML = '<div class="text-muted">No Autotask ticket created for this run.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnAutotask) {
|
||||||
|
if (run && run.autotask_ticket_id && (isResolved || isDeleted)) btnAutotask.textContent = 'Create new';
|
||||||
|
else btnAutotask.textContent = 'Create';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnAutotaskLink) {
|
||||||
|
var hasAtTicket = !!(run && run.autotask_ticket_id);
|
||||||
|
// Link existing is only meaningful when there is no PSA ticket linked to this run.
|
||||||
|
btnAutotaskLink.style.display = hasAtTicket ? 'none' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.__rcmRenderAutotaskInfo = renderAutotaskInfo;
|
||||||
|
|
||||||
|
window.__rcmSetAutotaskCreateLabel = function (run) {
|
||||||
|
if (!btnAutotask) return;
|
||||||
|
var hasTicket = !!(run && run.autotask_ticket_id);
|
||||||
|
var isResolved = !!(run && run.autotask_ticket_is_resolved);
|
||||||
|
var isDeleted = !!(run && run.autotask_ticket_is_deleted);
|
||||||
|
btnAutotask.textContent = (hasTicket && (isResolved || isDeleted)) ? 'Create new' : 'Create';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function isValidTicketCode(code) {
|
||||||
|
return /^T\d{8}\.\d{4}$/.test(code);
|
||||||
|
}
|
||||||
|
|
||||||
if (btnTicket) {
|
if (btnTicket) {
|
||||||
btnTicket.addEventListener('click', function () {
|
btnTicket.addEventListener('click', function () {
|
||||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||||
clearStatus();
|
clearStatus();
|
||||||
var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : '';
|
var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : '';
|
||||||
if (!ticket_code) {
|
if (!ticket_code) {
|
||||||
if (tStatus) tStatus.textContent = 'Ticket number is required.';
|
if (tStatus) tStatus.textContent = 'Ticket number is required.';
|
||||||
else alert('Ticket number is required.');
|
else alert('Ticket number is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^T\d{8}\.\d{4}$/.test(ticket_code)) {
|
if (!isValidTicketCode(ticket_code)) {
|
||||||
if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.';
|
if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.';
|
||||||
else alert('Invalid ticket number format. Expected TYYYYMMDD.####.');
|
else alert('Invalid ticket number format. Expected TYYYYMMDD.####.');
|
||||||
return;
|
return;
|
||||||
@ -885,7 +1001,7 @@ if (!ticket_code) {
|
|||||||
})
|
})
|
||||||
.then(function () {
|
.then(function () {
|
||||||
if (tCode) tCode.value = '';
|
if (tCode) tCode.value = '';
|
||||||
if (tStatus) tStatus.textContent = '';
|
if (tStatus) tStatus.textContent = '';
|
||||||
loadAlerts(currentRunId);
|
loadAlerts(currentRunId);
|
||||||
})
|
})
|
||||||
.catch(function (e) {
|
.catch(function (e) {
|
||||||
@ -895,6 +1011,176 @@ if (tStatus) tStatus.textContent = '';
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (btnAutotaskLink) {
|
||||||
|
var linkModalEl = document.getElementById('autotaskLinkModal');
|
||||||
|
var linkModal = linkModalEl ? bootstrap.Modal.getOrCreateInstance(linkModalEl) : null;
|
||||||
|
// Avoid stacked Bootstrap modals: temporarily hide the main Run Checks modal
|
||||||
|
// and re-open it when the Autotask link modal is closed.
|
||||||
|
var mainModalEl = document.getElementById('runChecksModal');
|
||||||
|
var mainModal = mainModalEl ? bootstrap.Modal.getOrCreateInstance(mainModalEl) : null;
|
||||||
|
var reopenMainAfterLinkModal = false;
|
||||||
|
var atlSearch = document.getElementById('atl_search');
|
||||||
|
var atlRefresh = document.getElementById('atl_refresh');
|
||||||
|
var atlStatus = document.getElementById('atl_status');
|
||||||
|
var atlTbody = document.getElementById('atl_tbody');
|
||||||
|
var atlEmpty = document.getElementById('atl_empty');
|
||||||
|
|
||||||
|
if (linkModalEl) {
|
||||||
|
linkModalEl.addEventListener('hidden.bs.modal', function () {
|
||||||
|
if (reopenMainAfterLinkModal && mainModal) {
|
||||||
|
reopenMainAfterLinkModal = false;
|
||||||
|
// Re-open the main modal so the normal Run Checks workflow continues.
|
||||||
|
mainModal.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAtlRows(items) {
|
||||||
|
if (!atlTbody) return;
|
||||||
|
atlTbody.innerHTML = '';
|
||||||
|
if (atlEmpty) atlEmpty.style.display = (items && items.length) ? 'none' : '';
|
||||||
|
(items || []).forEach(function (t) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var tdBtn = document.createElement('td');
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-sm btn-outline-primary';
|
||||||
|
btn.textContent = 'Link';
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||||
|
if (!confirm('Link ticket ' + (t.ticketNumber || '') + ' to this run?')) return;
|
||||||
|
if (atlStatus) atlStatus.textContent = 'Linking...';
|
||||||
|
apiJson('/api/run-checks/autotask-link-existing-ticket', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({run_id: currentRunId, ticket_id: t.id})
|
||||||
|
})
|
||||||
|
.then(function (j) {
|
||||||
|
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
|
||||||
|
if (atlStatus) atlStatus.textContent = '';
|
||||||
|
if (linkModal) linkModal.hide();
|
||||||
|
|
||||||
|
// Refresh modal data so UI reflects stored ticket linkage.
|
||||||
|
var keepRunId = currentRunId;
|
||||||
|
if (currentJobId) {
|
||||||
|
return fetch('/api/run-checks/details?job_id=' + encodeURIComponent(currentJobId))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (payload) {
|
||||||
|
currentPayload = payload;
|
||||||
|
var idx = 0;
|
||||||
|
var runs = (payload && payload.runs) || [];
|
||||||
|
for (var i = 0; i < runs.length; i++) {
|
||||||
|
if (String(runs[i].id) === String(keepRunId)) { idx = i; break; }
|
||||||
|
}
|
||||||
|
renderRun(payload, idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
if (atlStatus) atlStatus.textContent = e.message || 'Failed.';
|
||||||
|
else alert(e.message || 'Failed.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tdBtn.appendChild(btn);
|
||||||
|
|
||||||
|
var tdNum = document.createElement('td');
|
||||||
|
tdNum.textContent = (t.ticketNumber || '');
|
||||||
|
|
||||||
|
var tdTitle = document.createElement('td');
|
||||||
|
tdTitle.textContent = (t.title || '');
|
||||||
|
|
||||||
|
var tdStatus = document.createElement('td');
|
||||||
|
tdStatus.textContent = (t.status_label || String(t.status || ''));
|
||||||
|
|
||||||
|
tr.appendChild(tdBtn);
|
||||||
|
tr.appendChild(tdNum);
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
tr.appendChild(tdStatus);
|
||||||
|
atlTbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadExistingTickets() {
|
||||||
|
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||||
|
var q = atlSearch ? (atlSearch.value || '').trim() : '';
|
||||||
|
if (atlStatus) atlStatus.textContent = 'Loading...';
|
||||||
|
fetch('/api/run-checks/autotask-existing-tickets?run_id=' + encodeURIComponent(currentRunId) + '&q=' + encodeURIComponent(q))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (j) {
|
||||||
|
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
|
||||||
|
if (atlStatus) atlStatus.textContent = '';
|
||||||
|
renderAtlRows(j.items || []);
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
if (atlStatus) atlStatus.textContent = e.message || 'Failed.';
|
||||||
|
renderAtlRows([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atlRefresh) {
|
||||||
|
atlRefresh.addEventListener('click', function () { loadExistingTickets(); });
|
||||||
|
}
|
||||||
|
if (atlSearch) {
|
||||||
|
atlSearch.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter') { ev.preventDefault(); loadExistingTickets(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
btnAutotaskLink.addEventListener('click', function () {
|
||||||
|
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||||
|
if (atlStatus) atlStatus.textContent = '';
|
||||||
|
renderAtlRows([]);
|
||||||
|
// Show the existing Run Checks popup first, then switch to the Autotask popup.
|
||||||
|
// This prevents the main popup from breaking due to stacked modal backdrops.
|
||||||
|
if (mainModal) {
|
||||||
|
reopenMainAfterLinkModal = true;
|
||||||
|
mainModal.hide();
|
||||||
|
}
|
||||||
|
if (linkModal) linkModal.show();
|
||||||
|
loadExistingTickets();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnAutotask) {
|
||||||
|
btnAutotask.addEventListener('click', function () {
|
||||||
|
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||||
|
clearStatus();
|
||||||
|
if (atStatus) atStatus.textContent = 'Creating ticket...';
|
||||||
|
btnAutotask.disabled = true;
|
||||||
|
apiJson('/api/run-checks/autotask-ticket', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({run_id: 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; }
|
||||||
|
}
|
||||||
|
renderRun(payload, idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
if (atStatus) atStatus.textContent = e.message || 'Failed.';
|
||||||
|
else alert(e.message || 'Failed.');
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
// State will be recalculated by renderRun.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (btnRemark) {
|
if (btnRemark) {
|
||||||
btnRemark.addEventListener('click', function () {
|
btnRemark.addEventListener('click', function () {
|
||||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||||
@ -956,7 +1242,16 @@ if (tStatus) tStatus.textContent = '';
|
|||||||
|
|
||||||
currentRunId = run.id || null;
|
currentRunId = run.id || null;
|
||||||
if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus();
|
if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus();
|
||||||
if (window.__rcmSetCreateDisabled) window.__rcmSetCreateDisabled(!currentRunId);
|
if (window.__rcmRenderAutotaskInfo) window.__rcmRenderAutotaskInfo(run);
|
||||||
|
if (window.__rcmSetAutotaskCreateLabel) window.__rcmSetAutotaskCreateLabel(run);
|
||||||
|
if (window.__rcmSetCreateDisabled) {
|
||||||
|
if (autotaskEnabled) {
|
||||||
|
var canCreateAt = !!currentRunId && (!run.autotask_ticket_id || !!run.autotask_ticket_is_resolved);
|
||||||
|
window.__rcmSetCreateDisabled(!canCreateAt);
|
||||||
|
} else {
|
||||||
|
window.__rcmSetCreateDisabled(!currentRunId);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (btnMarkSuccessOverride) {
|
if (btnMarkSuccessOverride) {
|
||||||
var _rs = (run.status || '').toString().toLowerCase();
|
var _rs = (run.status || '').toString().toLowerCase();
|
||||||
var _canOverride = !!currentRunId && !run.missed && (_rs.indexOf('override') === -1) && (_rs.indexOf('success') === -1);
|
var _canOverride = !!currentRunId && !run.missed && (_rs.indexOf('override') === -1) && (_rs.indexOf('success') === -1);
|
||||||
@ -1144,9 +1439,10 @@ if (tStatus) tStatus.textContent = '';
|
|||||||
var dot = run.missed ? "dot-missed" : statusDotClass(run.status);
|
var dot = run.missed ? "dot-missed" : statusDotClass(run.status);
|
||||||
var dotHtml = dot ? ('<span class="status-dot ' + dot + ' me-2" aria-hidden="true"></span>') : '';
|
var dotHtml = dot ? ('<span class="status-dot ' + dot + ' me-2" aria-hidden="true"></span>') : '';
|
||||||
var reviewedMark = run.is_reviewed ? ' <span class="ms-2" title="Reviewed" aria-label="Reviewed">✔</span>' : '';
|
var reviewedMark = run.is_reviewed ? ' <span class="ms-2" title="Reviewed" aria-label="Reviewed">✔</span>' : '';
|
||||||
|
var ticketMark = run.autotask_ticket_id ? ' <span class="ms-2" title="Autotask ticket created" aria-label="Autotask ticket">🎫</span>' : '';
|
||||||
|
|
||||||
a.title = run.status || '';
|
a.title = run.status || '';
|
||||||
a.innerHTML = dotHtml + '<span class="text-nowrap">' + escapeHtml(run.run_at || 'Run') + '</span>' + reviewedMark;
|
a.innerHTML = dotHtml + '<span class="text-nowrap">' + escapeHtml(run.run_at || 'Run') + '</span>' + reviewedMark + ticketMark;
|
||||||
a.addEventListener('click', function (ev) {
|
a.addEventListener('click', function (ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
renderRun(data, idx);
|
renderRun(data, idx);
|
||||||
|
|||||||
@ -20,6 +20,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if section == 'imports' %}active{% endif %}" href="{{ url_for('main.settings', section='imports') }}">Imports</a>
|
<a class="nav-link {% if section == 'imports' %}active{% endif %}" href="{{ url_for('main.settings', section='imports') }}">Imports</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if section == 'integrations' %}active{% endif %}" href="{{ url_for('main.settings', section='integrations') }}">Integrations</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if section == 'maintenance' %}active{% endif %}" href="{{ url_for('main.settings', section='maintenance') }}">Maintenance</a>
|
<a class="nav-link {% if section == 'maintenance' %}active{% endif %}" href="{{ url_for('main.settings', section='maintenance') }}">Maintenance</a>
|
||||||
</li>
|
</li>
|
||||||
@ -316,6 +319,163 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% if section == 'integrations' %}
|
||||||
|
<form method="post" class="mb-4">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Autotask</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="autotask_enabled" name="autotask_enabled" {% if settings.autotask_enabled %}checked{% endif %} />
|
||||||
|
<label class="form-check-label" for="autotask_enabled">Enable Autotask integration</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="autotask_environment" class="form-label">Environment</label>
|
||||||
|
<select class="form-select" id="autotask_environment" name="autotask_environment">
|
||||||
|
<option value="" {% if not settings.autotask_environment %}selected{% endif %}>Select...</option>
|
||||||
|
<option value="sandbox" {% if settings.autotask_environment == 'sandbox' %}selected{% endif %}>Sandbox</option>
|
||||||
|
<option value="production" {% if settings.autotask_environment == 'production' %}selected{% endif %}>Production</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Use Sandbox for testing first.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="autotask_api_username" class="form-label">API Username</label>
|
||||||
|
<input type="text" class="form-control" id="autotask_api_username" name="autotask_api_username" value="{{ settings.autotask_api_username or '' }}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="autotask_api_password" class="form-label">API Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
id="autotask_api_password"
|
||||||
|
name="autotask_api_password"
|
||||||
|
placeholder="{% if has_autotask_password %}******** (stored){% else %}enter password{% endif %}"
|
||||||
|
/>
|
||||||
|
<div class="form-text">Leave empty to keep the existing password.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="autotask_tracking_identifier" class="form-label">Tracking Identifier (Integration Code)</label>
|
||||||
|
<input type="text" class="form-control" id="autotask_tracking_identifier" name="autotask_tracking_identifier" value="{{ settings.autotask_tracking_identifier or '' }}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="autotask_base_url" class="form-label">Backupchecks Base URL</label>
|
||||||
|
<input type="text" class="form-control" id="autotask_base_url" name="autotask_base_url" value="{{ settings.autotask_base_url or '' }}" placeholder="https://backupchecks.example.com" />
|
||||||
|
<div class="form-text">Required later for creating stable links to Job Details pages.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Ticket defaults</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="autotask_default_queue_id" class="form-label">Default Queue</label>
|
||||||
|
<select class="form-select" id="autotask_default_queue_id" name="autotask_default_queue_id">
|
||||||
|
<option value="" {% if not settings.autotask_default_queue_id %}selected{% endif %}>Select...</option>
|
||||||
|
{% for q in autotask_queues %}
|
||||||
|
<option value="{{ q.id }}" {% if settings.autotask_default_queue_id == q.id %}selected{% endif %}>{{ q.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Requires refreshed reference data.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="autotask_default_ticket_source_id" class="form-label">Ticket Source</label>
|
||||||
|
<select class="form-select" id="autotask_default_ticket_source_id" name="autotask_default_ticket_source_id">
|
||||||
|
<option value="" {% if not settings.autotask_default_ticket_source_id %}selected{% endif %}>Select...</option>
|
||||||
|
{% for s in autotask_ticket_sources %}
|
||||||
|
<option value="{{ s.id }}" {% if settings.autotask_default_ticket_source_id == s.id %}selected{% endif %}>{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Requires refreshed reference data.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="autotask_default_ticket_status" class="form-label">Default Ticket Status</label>
|
||||||
|
<select class="form-select" id="autotask_default_ticket_status" name="autotask_default_ticket_status">
|
||||||
|
<option value="" {% if not settings.autotask_default_ticket_status %}selected{% endif %}>Select...</option>
|
||||||
|
{% for st in autotask_ticket_statuses %}
|
||||||
|
<option value="{{ st.id }}" {% if settings.autotask_default_ticket_status == st.id %}selected{% endif %}>{{ st.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Required for Autotask ticket creation. Requires refreshed reference data.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="autotask_priority_warning" class="form-label">Priority for Warning</label>
|
||||||
|
<select class="form-select" id="autotask_priority_warning" name="autotask_priority_warning">
|
||||||
|
<option value="" {% if not settings.autotask_priority_warning %}selected{% endif %}>Select...</option>
|
||||||
|
{% for p in autotask_priorities %}
|
||||||
|
<option value="{{ p.id }}" {% if settings.autotask_priority_warning == p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Requires refreshed reference data.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="autotask_priority_error" class="form-label">Priority for Error</label>
|
||||||
|
<select class="form-select" id="autotask_priority_error" name="autotask_priority_error">
|
||||||
|
<option value="" {% if not settings.autotask_priority_error %}selected{% endif %}>Select...</option>
|
||||||
|
{% for p in autotask_priorities %}
|
||||||
|
<option value="{{ p.id }}" {% if settings.autotask_priority_error == p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Requires refreshed reference data.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mt-2">Priorities are loaded from Autotask to avoid manual ID mistakes.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary">Save settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">Diagnostics & reference data</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="text-muted small">Last reference data sync</div>
|
||||||
|
<div class="fw-semibold">
|
||||||
|
{% if autotask_last_sync_at %}
|
||||||
|
{{ autotask_last_sync_at }}
|
||||||
|
{% else %}
|
||||||
|
never
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-2">
|
||||||
|
Cached Queues: {{ autotask_queues|length }}<br />
|
||||||
|
Cached Ticket Sources: {{ autotask_ticket_sources|length }}<br />
|
||||||
|
Cached Ticket Statuses: {{ autotask_ticket_statuses|length }}<br />
|
||||||
|
Cached Priorities: {{ autotask_priorities|length }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex flex-wrap gap-2 justify-content-md-end">
|
||||||
|
<form method="post" action="{{ url_for('main.settings_autotask_test_connection') }}">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary">Test connection</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('main.settings_autotask_refresh_reference_data') }}">
|
||||||
|
<button type="submit" class="btn btn-outline-primary">Refresh reference data</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mt-2 text-md-end">Refresh loads Queues, Ticket Sources, Ticket Statuses, and Priorities from Autotask for dropdown usage.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
{% if section == 'maintenance' %}
|
{% if section == 'maintenance' %}
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
|
|||||||
@ -0,0 +1,412 @@
|
|||||||
|
# Backupchecks – Autotask Integration
|
||||||
|
|
||||||
|
## Functional Design
|
||||||
|
|
||||||
|
*Last updated: 2026-01-16*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scope & Goals
|
||||||
|
|
||||||
|
This document describes the **functional design and agreed decisions** for the first phase of the Autotask integration in Backupchecks.
|
||||||
|
|
||||||
|
Goals for phase 1:
|
||||||
|
|
||||||
|
- Allow operators to **manually create Autotask tickets** from Backupchecks.
|
||||||
|
- Ensure **full operator control** over when a ticket is created.
|
||||||
|
- Prevent ticket spam and duplicate tickets.
|
||||||
|
- Maintain clear ownership between Backupchecks and Autotask.
|
||||||
|
- Provide a safe and auditable way to resolve tickets from Backupchecks.
|
||||||
|
|
||||||
|
Out of scope for phase 1:
|
||||||
|
|
||||||
|
- Automatic ticket creation
|
||||||
|
- Automatic ticket closing on success
|
||||||
|
- Issue correlation across multiple runs
|
||||||
|
- Time entry creation or modification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Principles (Leading)
|
||||||
|
|
||||||
|
These principles apply to all design and implementation choices:
|
||||||
|
|
||||||
|
- Autotask is an **external authoritative system** (PSA).
|
||||||
|
- Backupchecks is a **consumer**, not an owner, of PSA data.
|
||||||
|
- **IDs are leading**, names are display-only.
|
||||||
|
- All PSA mappings are **explicit**, never implicit or automatic.
|
||||||
|
- Operators always retain **manual control**.
|
||||||
|
- Renaming in Autotask must **never break mappings**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Customer ↔ Autotask Company Mapping
|
||||||
|
|
||||||
|
### 3.1 Mapping model
|
||||||
|
|
||||||
|
- Mapping is configured in the **Customers** screen.
|
||||||
|
- Mapping is a **1-to-1 explicit relationship**.
|
||||||
|
- Stored values per customer:
|
||||||
|
- PSA type: autotask
|
||||||
|
- Autotask Company ID (leading)
|
||||||
|
- Autotask Company Name (cached for display)
|
||||||
|
- Last sync timestamp
|
||||||
|
- Mapping status: ok | renamed | missing | invalid
|
||||||
|
|
||||||
|
The Autotask Company ID is the source of truth. The name exists only for UI clarity.
|
||||||
|
|
||||||
|
### 3.2 Name synchronisation
|
||||||
|
|
||||||
|
- If the company name is changed in Autotask:
|
||||||
|
- Backupchecks updates the cached name automatically.
|
||||||
|
- The mapping remains intact.
|
||||||
|
- Backupchecks customer names are independent and never overwritten.
|
||||||
|
|
||||||
|
### 3.3 Failure scenarios
|
||||||
|
|
||||||
|
- Autotask company deleted or inaccessible:
|
||||||
|
- Mapping status becomes invalid.
|
||||||
|
- Ticket creation is blocked.
|
||||||
|
- UI clearly indicates broken mapping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Ticket Creation Model
|
||||||
|
|
||||||
|
### 4.1 Operator-driven creation
|
||||||
|
|
||||||
|
- Tickets are created only via an explicit operator action.
|
||||||
|
- Location: Run Checks page.
|
||||||
|
- Manual ticket number input is removed.
|
||||||
|
- A new action replaces it: Create Autotask ticket.
|
||||||
|
|
||||||
|
### 4.2 One ticket per run
|
||||||
|
|
||||||
|
- Exactly one ticket per run.
|
||||||
|
- A run can never create multiple tickets.
|
||||||
|
- If a ticket exists:
|
||||||
|
- Creation action is replaced by Open ticket.
|
||||||
|
|
||||||
|
### 4.3 Ticket contents (baseline)
|
||||||
|
|
||||||
|
Minimum ticket fields:
|
||||||
|
|
||||||
|
- Subject: [Backupchecks] - - 
|
||||||
|
- Description:
|
||||||
|
- Run date/time
|
||||||
|
- Backup type and job
|
||||||
|
- Affected objects
|
||||||
|
- Error or warning messages
|
||||||
|
- Reference to Backupchecks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ticket State Tracking in Backupchecks
|
||||||
|
|
||||||
|
Per run, Backupchecks stores:
|
||||||
|
|
||||||
|
- Autotask Ticket ID
|
||||||
|
- Autotask Ticket Number
|
||||||
|
- Ticket URL
|
||||||
|
- Created by
|
||||||
|
- Created at timestamp
|
||||||
|
- Last known ticket status (snapshot)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ticket Resolution from Backupchecks
|
||||||
|
|
||||||
|
Backupchecks may resolve a ticket only if:
|
||||||
|
|
||||||
|
- The ticket exists
|
||||||
|
- The ticket is not already closed
|
||||||
|
- No time entries are present
|
||||||
|
|
||||||
|
If time entries exist, the ticket is not closed and an internal system note is added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Backupchecks Settings
|
||||||
|
|
||||||
|
Settings → Extensions & Integrations → Autotask
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
|
||||||
|
- Enable integration (on/off)
|
||||||
|
- Environment
|
||||||
|
- API credentials
|
||||||
|
- Tracking Identifier
|
||||||
|
- Default queue and status
|
||||||
|
- Priority mapping
|
||||||
|
|
||||||
|
### 7.1 Enable / disable behaviour (mandatory)
|
||||||
|
|
||||||
|
Backupchecks must support switching the Autotask integration **on and off at any time**.
|
||||||
|
|
||||||
|
When Autotask integration is **enabled**:
|
||||||
|
- Autotask actions are available (create ticket, resolve ticket, link existing ticket).
|
||||||
|
- Ticket polling/synchronisation (Phase 2) is active.
|
||||||
|
|
||||||
|
When Autotask integration is **disabled**:
|
||||||
|
- Backupchecks falls back to the manual workflow:
|
||||||
|
- Operators can manually enter ticket numbers.
|
||||||
|
- No Autotask API calls are executed.
|
||||||
|
- No polling is performed.
|
||||||
|
- No resolve action is available (as it would require Autotask calls).
|
||||||
|
- Existing stored Autotask references remain visible for audit/history.
|
||||||
|
|
||||||
|
The enable/disable switch must be reversible and must not require data deletion or migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Roles & Permissions
|
||||||
|
|
||||||
|
- Admin / Operator: create and resolve tickets
|
||||||
|
- Reporter: view-only access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Explicit Non-Goals (Phase 1)
|
||||||
|
|
||||||
|
- Automatic ticket creation
|
||||||
|
- Automatic ticket closing
|
||||||
|
- Time entry handling
|
||||||
|
- Multiple tickets per run
|
||||||
|
- PSA-side logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Phase 1 Summary
|
||||||
|
|
||||||
|
Phase 1 delivers controlled, operator-driven PSA integration with a strong focus on auditability and predictability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase 2 – Ticket State Synchronisation (PSA-driven)
|
||||||
|
|
||||||
|
This phase introduces **PSA-driven state awareness** for already linked tickets. The goal is to keep Backupchecks aligned with Autotask **without introducing active control or automation**.
|
||||||
|
|
||||||
|
### 11.1 Polling strategy (Run Checks entry point)
|
||||||
|
|
||||||
|
- Polling is executed **only when Autotask integration is enabled**.
|
||||||
|
- When the **Run Checks** page is opened, Backupchecks performs a **targeted poll** to Autotask.
|
||||||
|
|
||||||
|
- When the **Run Checks** page is opened, Backupchecks performs a **targeted poll** to Autotask.
|
||||||
|
- Only tickets that are:
|
||||||
|
- Linked to runs shown on the page, and
|
||||||
|
- Not in a terminal state inside Backupchecks
|
||||||
|
are included.
|
||||||
|
- Tickets already marked as resolved or broken are excluded.
|
||||||
|
|
||||||
|
This prevents unnecessary API calls and limits polling to operator-relevant context.
|
||||||
|
|
||||||
|
### 11.2 Active-ticket-only retrieval
|
||||||
|
|
||||||
|
- Backupchecks only queries tickets that are considered **active** in Autotask.
|
||||||
|
- Completed or closed tickets are not included in the active-ticket query.
|
||||||
|
- This ensures minimal load and avoids repeated retrieval of historical data.
|
||||||
|
|
||||||
|
### 11.3 PSA-driven completion handling
|
||||||
|
|
||||||
|
If Autotask reports a ticket with status **Completed**:
|
||||||
|
|
||||||
|
- The linked run in Backupchecks is automatically marked as **Resolved**.
|
||||||
|
- The resolution is explicitly flagged as:
|
||||||
|
- **Resolved by PSA**
|
||||||
|
- Backupchecks does not add notes or modify the ticket in Autotask.
|
||||||
|
|
||||||
|
UI behaviour:
|
||||||
|
- Operators can clearly see that the resolution originated from the PSA.
|
||||||
|
- A visual indicator highlights that the underlying backup issue may still require verification.
|
||||||
|
|
||||||
|
### 11.4 Operator awareness and follow-up
|
||||||
|
|
||||||
|
When a ticket is resolved by PSA:
|
||||||
|
|
||||||
|
- Backupchecks does not assume the technical issue is resolved.
|
||||||
|
- Operators are expected to:
|
||||||
|
- Review the related run
|
||||||
|
- Decide whether further action is required inside Backupchecks
|
||||||
|
|
||||||
|
No automatic reopening or ticket creation is performed.
|
||||||
|
|
||||||
|
### 11.5 Deleted ticket detection
|
||||||
|
|
||||||
|
If a linked ticket is **deleted in Autotask**:
|
||||||
|
|
||||||
|
- Backupchecks detects this during polling.
|
||||||
|
- The ticket linkage is marked as:
|
||||||
|
- **Deleted in PSA**
|
||||||
|
|
||||||
|
UI behaviour:
|
||||||
|
- A clear warning is shown to the operator.
|
||||||
|
- The historical ticket reference remains visible for audit purposes.
|
||||||
|
- Ticket-related actions are blocked until the operator:
|
||||||
|
- Links a replacement ticket, or
|
||||||
|
- Creates a new Autotask ticket
|
||||||
|
|
||||||
|
### 11.6 Ticket resolution from Backupchecks (operator-driven)
|
||||||
|
|
||||||
|
Phase 2 includes implementation of **manual ticket resolution** from Backupchecks under the already defined Phase 1 rules.
|
||||||
|
|
||||||
|
- Resolution is always an explicit operator action (no automation).
|
||||||
|
- Resolution rules remain leading (see Chapter 6):
|
||||||
|
- Ticket must exist
|
||||||
|
- Ticket must not already be closed
|
||||||
|
- Ticket may only be closed by Backupchecks if **no time entries** exist
|
||||||
|
- If time entries exist, Backupchecks adds an internal system note and leaves the ticket open
|
||||||
|
|
||||||
|
UI behaviour (Run Checks and Job Details):
|
||||||
|
- Show a Resolve ticket action only when a validated Autotask Ticket ID exists.
|
||||||
|
- When resolution succeeds, update the run to Resolved and store:
|
||||||
|
- Resolved timestamp
|
||||||
|
- Resolved by (operator)
|
||||||
|
- Resolution origin: Resolved by Backupchecks
|
||||||
|
|
||||||
|
Important alignment with PSA-driven sync:
|
||||||
|
- If Autotask later reports the ticket as Completed, Backupchecks keeps the run resolved, but the origin remains:
|
||||||
|
- Resolved by Backupchecks
|
||||||
|
- If Autotask reports completion before the operator resolves it, Backupchecks sets:
|
||||||
|
- Resolved by PSA
|
||||||
|
|
||||||
|
### 11.7 Explicit non-goals (Phase 2)
|
||||||
|
|
||||||
|
The following remain explicitly out of scope:
|
||||||
|
|
||||||
|
- Automatic ticket creation
|
||||||
|
- Automatic ticket reopening
|
||||||
|
- Automatic resolution without operator intent
|
||||||
|
- Status pushing from Backupchecks to Autotask (except the explicit Resolve action described above)
|
||||||
|
- Any modification of existing Autotask ticket content (except fixed-format internal system notes used during resolution rules)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Future Design Considerations (Post-Phase 2)
|
||||||
|
|
||||||
|
The following sections describe **future design intent only**. They are explicitly **out of scope for Phase 2** and introduce no implementation commitments.
|
||||||
|
|
||||||
|
### 12.1 Ticket lifecycle awareness (read-only intelligence)
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
Provide better insight into the state of linked tickets without adding actions.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Periodic read-only retrieval of ticket status
|
||||||
|
- Display of:
|
||||||
|
- Current status
|
||||||
|
- Queue
|
||||||
|
- Assigned resource (owner)
|
||||||
|
|
||||||
|
Shown in:
|
||||||
|
- Run Checks
|
||||||
|
- Job Details
|
||||||
|
- Tickets / Remarks overview
|
||||||
|
|
||||||
|
Explicitly excluded:
|
||||||
|
- No automatic actions
|
||||||
|
- No status synchronisation back to Autotask
|
||||||
|
|
||||||
|
Value:
|
||||||
|
- Operators need to open Autotask less frequently
|
||||||
|
- Faster visibility into whether a ticket has been picked up
|
||||||
|
|
||||||
|
### 12.2 Operator notes & context enrichment
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
Add contextual information to existing tickets without taking ownership away from the PSA.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Add internal/system notes from Backupchecks
|
||||||
|
- Notes are always written in a fixed format
|
||||||
|
- Notes are added only by explicit operator action
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Never overwrite existing ticket content
|
||||||
|
- Never create customer-facing notes
|
||||||
|
- Never generate notes automatically
|
||||||
|
|
||||||
|
Value:
|
||||||
|
- Tickets remain up to date without duplication
|
||||||
|
- Autotask remains the authoritative system
|
||||||
|
|
||||||
|
### 12.3 Controlled assistance (semi-automatic support)
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
Support operator decision-making without introducing automation.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Suggestion: a similar error already has an open ticket
|
||||||
|
- Suggestion: previous run was successful, consider resolving the ticket
|
||||||
|
- Highlight repeated failures without any linked ticket
|
||||||
|
|
||||||
|
Important constraints:
|
||||||
|
- Suggestions only
|
||||||
|
- No automatic ticket creation
|
||||||
|
- No automatic ticket closure
|
||||||
|
|
||||||
|
Value:
|
||||||
|
- Reduced human error
|
||||||
|
- No loss of operator control
|
||||||
|
|
||||||
|
### 12.4 Cross-run correlation (analytical)
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
Provide insight into structural or recurring problems before tickets are created.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Detection of repeated failures across multiple runs
|
||||||
|
- Detection of the same object with the same error over time
|
||||||
|
- Visualisation inside Backupchecks only
|
||||||
|
|
||||||
|
Explicitly excluded:
|
||||||
|
- Ticket bundling
|
||||||
|
- Ticket merging
|
||||||
|
- Any Autotask-side modifications
|
||||||
|
|
||||||
|
Value:
|
||||||
|
- Better decisions prior to ticket creation
|
||||||
|
- Reduced noise in the PSA
|
||||||
|
|
||||||
|
### 12.5 Multi-PSA abstraction (design preparation)
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
Prepare the internal design for future PSA support without implementing it.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- PSA-agnostic internal models:
|
||||||
|
- Ticket
|
||||||
|
- Company mapping
|
||||||
|
- Resolution rules
|
||||||
|
- Autotask remains the only concrete implementation
|
||||||
|
|
||||||
|
Why this is documented now:
|
||||||
|
- Prevents Autotask-specific technical debt
|
||||||
|
- Keeps the architecture open for other PSA platforms
|
||||||
|
|
||||||
|
### 12.6 Governance & audit depth
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
Ensure full traceability for MSP and enterprise environments.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Extended audit logging:
|
||||||
|
- Who created, linked or resolved a ticket
|
||||||
|
- When the action occurred
|
||||||
|
- From which run the action originated
|
||||||
|
- Read-only export capabilities
|
||||||
|
- Optional compliance-oriented views
|
||||||
|
|
||||||
|
Value:
|
||||||
|
- MSP and enterprise readiness
|
||||||
|
- Supports internal and external audits
|
||||||
|
|
||||||
|
### 12.7 Explicit non-directions
|
||||||
|
|
||||||
|
The following are intentionally excluded unless a strategic decision is made later:
|
||||||
|
|
||||||
|
- Automatic ticket creation
|
||||||
|
- Automatic ticket handling
|
||||||
|
- Time registration
|
||||||
|
- Mutation of existing ticket content
|
||||||
|
- PSA business logic inside Backupchecks
|
||||||
|
|
||||||
@ -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.
|
||||||
|
|
||||||
432
docs/backupchecks_autotask_integration_phase_2_implementation.md
Normal file
432
docs/backupchecks_autotask_integration_phase_2_implementation.md
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
# Backupchecks – Autotask Integration
|
||||||
|
|
||||||
|
## Phase 2 – Implementation Design
|
||||||
|
|
||||||
|
*Document type: Implementation design*
|
||||||
|
*Scope: Phase 2 only*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document describes the **technical implementation approach** for Phase 2 of the Autotask integration. Phase 2 focuses on **ticket state synchronisation and operator-driven resolution**, based on the approved functional design.
|
||||||
|
|
||||||
|
No future concepts or post–Phase 2 ideas are included in this document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Preconditions
|
||||||
|
|
||||||
|
Phase 2 assumes:
|
||||||
|
|
||||||
|
- Phase 1 is fully implemented and deployed
|
||||||
|
- Autotask integration can be enabled and disabled at runtime
|
||||||
|
- Tickets are already linked to runs via Autotask Ticket ID
|
||||||
|
- Backupchecks is not the authoritative system for ticket state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. High-level Behaviour Overview
|
||||||
|
|
||||||
|
Phase 2 is implemented in **controlled sub-phases**. Each phase introduces a limited set of actions and ends with an explicit **functional validation moment** before continuing.
|
||||||
|
|
||||||
|
The sub-phases are:
|
||||||
|
|
||||||
|
- Phase 2.1 – Read-only ticket polling
|
||||||
|
- Phase 2.2 – PSA-driven resolution handling
|
||||||
|
- Phase 2.3 – Deleted ticket detection
|
||||||
|
- Phase 2.4 – Manual ticket resolution from Backupchecks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Phase 2.1 – Read-only Ticket Polling
|
||||||
|
|
||||||
|
### 4.1 Scope
|
||||||
|
|
||||||
|
This phase introduces **read-only polling** of Autotask ticket state. No mutations or resolution logic is applied.
|
||||||
|
|
||||||
|
### 4.2 Trigger point
|
||||||
|
|
||||||
|
Polling is triggered when:
|
||||||
|
|
||||||
|
- Autotask integration is enabled
|
||||||
|
- The **Run Checks** page is opened
|
||||||
|
|
||||||
|
There is no background scheduler or periodic task.
|
||||||
|
|
||||||
|
### 4.3 Polling scope
|
||||||
|
|
||||||
|
Only tickets that meet **all** of the following criteria are included:
|
||||||
|
|
||||||
|
- Ticket is linked to a run visible on the Run Checks page
|
||||||
|
- Run is not already resolved in Backupchecks
|
||||||
|
- Ticket is not marked as deleted or invalid
|
||||||
|
|
||||||
|
### 4.4 Autotask query strategy
|
||||||
|
|
||||||
|
- Query only **active tickets** in Autotask
|
||||||
|
- Ticket ID is always used as the lookup key
|
||||||
|
|
||||||
|
### 4.5 Control moment – Phase 2.1
|
||||||
|
|
||||||
|
Functional validation:
|
||||||
|
|
||||||
|
- Run Checks page loads without delay
|
||||||
|
- Correct tickets are polled
|
||||||
|
- No state changes occur in Backupchecks
|
||||||
|
- No Autotask data is modified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Polling scope
|
||||||
|
|
||||||
|
Only tickets that meet **all** of the following criteria are included:
|
||||||
|
|
||||||
|
- Ticket is linked to a run visible on the Run Checks page
|
||||||
|
- Run is not already resolved in Backupchecks
|
||||||
|
- Ticket is not marked as deleted or invalid
|
||||||
|
|
||||||
|
This guarantees:
|
||||||
|
|
||||||
|
- Minimal API usage
|
||||||
|
- Operator-context-only data retrieval
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Autotask query strategy
|
||||||
|
|
||||||
|
- Query only **active tickets** in Autotask
|
||||||
|
- Completed / closed tickets are excluded from the active query
|
||||||
|
- Ticket ID is always used as the lookup key
|
||||||
|
|
||||||
|
If an expected ticket is not returned:
|
||||||
|
|
||||||
|
- A follow-up single-ticket lookup is executed
|
||||||
|
- If still not found, the ticket is treated as **deleted in PSA**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phase 2.2 – PSA-driven Resolution Handling
|
||||||
|
|
||||||
|
### 5.1 Scope
|
||||||
|
|
||||||
|
This phase adds **state interpretation** on top of read-only polling.
|
||||||
|
|
||||||
|
### 5.2 Completed ticket handling
|
||||||
|
|
||||||
|
If Autotask reports the ticket status as **Completed**:
|
||||||
|
|
||||||
|
- Mark the linked run as **Resolved**
|
||||||
|
- Set resolution origin to:
|
||||||
|
- `Resolved by PSA`
|
||||||
|
- Store resolution timestamp
|
||||||
|
|
||||||
|
No write-back to Autotask is performed.
|
||||||
|
|
||||||
|
### 5.3 Active ticket handling
|
||||||
|
|
||||||
|
If the ticket exists and is active:
|
||||||
|
|
||||||
|
- Update cached ticket status snapshot only
|
||||||
|
- No state change in Backupchecks
|
||||||
|
|
||||||
|
### 5.4 Control moment – Phase 2.2
|
||||||
|
|
||||||
|
Functional validation:
|
||||||
|
|
||||||
|
- PSA-completed tickets resolve runs correctly
|
||||||
|
- Resolution origin is shown correctly
|
||||||
|
- Active tickets do not alter run state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Completed ticket
|
||||||
|
|
||||||
|
If Autotask reports the ticket status as **Completed**:
|
||||||
|
|
||||||
|
- Mark the linked run as **Resolved**
|
||||||
|
- Set resolution origin to:
|
||||||
|
- `Resolved by PSA`
|
||||||
|
- Store resolution timestamp
|
||||||
|
|
||||||
|
No write-back to Autotask is performed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Deleted or missing ticket
|
||||||
|
|
||||||
|
If the ticket cannot be found in Autotask:
|
||||||
|
|
||||||
|
- Mark ticket linkage as:
|
||||||
|
- `Deleted in PSA`
|
||||||
|
- Block ticket-related actions
|
||||||
|
- Preserve historical references (ID, number, URL)
|
||||||
|
|
||||||
|
Operator must explicitly link or create a replacement ticket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Phase 2.3 – Deleted Ticket Detection
|
||||||
|
|
||||||
|
### 6.1 Scope
|
||||||
|
|
||||||
|
This phase introduces detection of tickets that are removed from Autotask.
|
||||||
|
|
||||||
|
### 6.2 Detection logic
|
||||||
|
|
||||||
|
If a linked ticket is not returned by the active-ticket query:
|
||||||
|
|
||||||
|
- Execute a single-ticket lookup
|
||||||
|
- If still not found:
|
||||||
|
- Mark ticket linkage as `Deleted in PSA`
|
||||||
|
|
||||||
|
### 6.3 Behaviour
|
||||||
|
|
||||||
|
- Ticket actions are blocked
|
||||||
|
- Historical references remain visible
|
||||||
|
- Operator must explicitly relink or recreate a ticket
|
||||||
|
|
||||||
|
### 6.4 Control moment – Phase 2.3
|
||||||
|
|
||||||
|
Functional validation:
|
||||||
|
|
||||||
|
- Deleted tickets are detected reliably
|
||||||
|
- Warning is visible in UI
|
||||||
|
- No silent unlinking occurs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Phase 2.4 – Manual Ticket Resolution (Backupchecks → Autotask)
|
||||||
|
|
||||||
|
### 7.1 Preconditions
|
||||||
|
|
||||||
|
The Resolve action is available only if:
|
||||||
|
|
||||||
|
- Autotask integration is enabled
|
||||||
|
- A valid Autotask Ticket ID exists
|
||||||
|
- Ticket is not already closed in PSA
|
||||||
|
|
||||||
|
### 7.2 Resolution flow
|
||||||
|
|
||||||
|
1. Operator triggers **Resolve ticket**
|
||||||
|
2. Backupchecks retrieves ticket details from Autotask
|
||||||
|
3. Time entry check is executed
|
||||||
|
|
||||||
|
#### Case A – No time entries
|
||||||
|
|
||||||
|
- Ticket is set to completed in Autotask
|
||||||
|
- Run is marked as **Resolved**
|
||||||
|
- Resolution origin:
|
||||||
|
- `Resolved by Backupchecks`
|
||||||
|
|
||||||
|
#### Case B – Time entries exist
|
||||||
|
|
||||||
|
- Ticket is not closed
|
||||||
|
- Fixed-format internal system note is added
|
||||||
|
- Run remains unresolved
|
||||||
|
|
||||||
|
### 7.3 Control moment – Phase 2.4
|
||||||
|
|
||||||
|
Functional validation:
|
||||||
|
|
||||||
|
- Resolution works only under defined rules
|
||||||
|
- Time entry logic is respected
|
||||||
|
- Resolution origin is persisted correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 Resolution flow
|
||||||
|
|
||||||
|
1. Operator triggers **Resolve ticket**
|
||||||
|
2. Backupchecks retrieves ticket details from Autotask
|
||||||
|
3. Time entry check is executed
|
||||||
|
|
||||||
|
#### Case A – No time entries
|
||||||
|
|
||||||
|
- Ticket is set to the configured completed status in Autotask
|
||||||
|
- Run is marked as **Resolved**
|
||||||
|
- Resolution origin:
|
||||||
|
- `Resolved by Backupchecks`
|
||||||
|
|
||||||
|
#### Case B – Time entries exist
|
||||||
|
|
||||||
|
- Ticket is not closed
|
||||||
|
- A fixed-format internal system note is added
|
||||||
|
- Run remains unresolved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.3 Post-resolution sync alignment
|
||||||
|
|
||||||
|
If a ticket resolved by Backupchecks is later polled as Completed:
|
||||||
|
|
||||||
|
- Backupchecks keeps the existing resolved state
|
||||||
|
- Resolution origin remains:
|
||||||
|
- `Resolved by Backupchecks`
|
||||||
|
|
||||||
|
If Autotask resolves the ticket first:
|
||||||
|
|
||||||
|
- Backupchecks sets:
|
||||||
|
- `Resolved by PSA`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Data Model Adjustments
|
||||||
|
|
||||||
|
Phase 2 requires the following additional fields per run:
|
||||||
|
|
||||||
|
- `ticket_last_polled_at`
|
||||||
|
- `ticket_resolution_origin`:
|
||||||
|
- `psa`
|
||||||
|
- `backupchecks`
|
||||||
|
- `ticket_deleted_in_psa` (boolean)
|
||||||
|
|
||||||
|
Existing Phase 1 fields remain unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UI Behaviour
|
||||||
|
|
||||||
|
### 8.1 Run Checks
|
||||||
|
|
||||||
|
- Visual indicator for linked tickets
|
||||||
|
- Resolution origin badge:
|
||||||
|
- Resolved by PSA
|
||||||
|
- Resolved by Backupchecks
|
||||||
|
- Warning banner for deleted tickets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.2 Job Details
|
||||||
|
|
||||||
|
- Same indicators as Run Checks
|
||||||
|
- Resolve action visibility follows resolution rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Error Handling & Logging
|
||||||
|
|
||||||
|
- All Autotask API calls are logged with:
|
||||||
|
- Correlation ID
|
||||||
|
- Ticket ID
|
||||||
|
- Run ID
|
||||||
|
|
||||||
|
- Polling failures do not block page rendering
|
||||||
|
- Partial polling failures are tolerated per ticket
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Explicit Non-Implementation (Phase 2)
|
||||||
|
|
||||||
|
The following are **not implemented**:
|
||||||
|
|
||||||
|
- Background schedulers
|
||||||
|
- Automatic ticket creation
|
||||||
|
- Automatic ticket reopening
|
||||||
|
- Status pushing without operator action
|
||||||
|
- Ticket content mutation beyond fixed system notes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase-based Implementation Workflow
|
||||||
|
|
||||||
|
For each Phase 2 sub-phase, a **dedicated chat** must be used. This ensures focus, traceability, and controlled implementation.
|
||||||
|
|
||||||
|
The instructions below must be copied **verbatim** when starting a new chat for the corresponding phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2.1 – Chat Instructions
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
Implement **read-only ticket polling** when the Run Checks page is opened.
|
||||||
|
|
||||||
|
Chat instructions:
|
||||||
|
|
||||||
|
- Scope is limited to Phase 2.1 only
|
||||||
|
- No ticket state changes are allowed
|
||||||
|
- No resolve, close, or mutation logic may be added
|
||||||
|
- Only backend polling and UI visibility updates are permitted
|
||||||
|
- All changes must be functionally testable on Run Checks
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Polling executes only when Run Checks is opened
|
||||||
|
- Only relevant active tickets are queried
|
||||||
|
- No Backupchecks run state is modified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2.2 – Chat Instructions
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
Implement **PSA-driven resolution handling** based on polling results.
|
||||||
|
|
||||||
|
Chat instructions:
|
||||||
|
|
||||||
|
- Phase 2.1 must already be completed and validated
|
||||||
|
- Only Completed ticket handling may be added
|
||||||
|
- Resolution origin must be stored and displayed
|
||||||
|
- No Backupchecks-initiated ticket changes are allowed
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Completed tickets resolve runs correctly
|
||||||
|
- Resolution origin is accurate and persistent
|
||||||
|
- Active tickets remain unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2.3 – Chat Instructions
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
Implement **deleted ticket detection and operator visibility**.
|
||||||
|
|
||||||
|
Chat instructions:
|
||||||
|
|
||||||
|
- Phase 2.1 and 2.2 must already be completed
|
||||||
|
- Detection must be explicit and non-destructive
|
||||||
|
- Historical ticket references must remain intact
|
||||||
|
- UI must clearly indicate deleted state
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Deleted tickets are reliably detected
|
||||||
|
- Operators are clearly informed
|
||||||
|
- Ticket actions are blocked correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2.4 – Chat Instructions
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
Implement **manual ticket resolution from Backupchecks to Autotask**.
|
||||||
|
|
||||||
|
Chat instructions:
|
||||||
|
|
||||||
|
- All previous Phase 2 sub-phases must be completed
|
||||||
|
- Resolution must be strictly operator-driven
|
||||||
|
- Time entry rules must be enforced exactly
|
||||||
|
- Resolution origin must be stored correctly
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Tickets are resolved only when allowed
|
||||||
|
- Time entry edge cases behave correctly
|
||||||
|
- State remains consistent with PSA polling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Phase 2 Completion Criteria
|
||||||
|
|
||||||
|
Phase 2 is considered complete when:
|
||||||
|
|
||||||
|
- All Phase 2 sub-phases have passed their control moments
|
||||||
|
- No cross-phase leakage of logic exists
|
||||||
|
- Enable/disable integration behaviour is respected
|
||||||
|
- Functional behaviour matches the approved design exactly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
End of Phase 2 implementation document.
|
||||||
|
|
||||||
@ -1,3 +1,448 @@
|
|||||||
|
## v20260115-01-autotask-settings
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Added initial Autotask integration settings structure to Backupchecks.
|
||||||
|
- Introduced new system settings demonstrating Autotask configuration fields such as enable toggle, environment selection, credentials, tracking identifier, and Backupchecks base URL.
|
||||||
|
- Prepared data model and persistence layer to store Autotask-related configuration.
|
||||||
|
- Laid groundwork for future validation and integration logic without enabling ticket creation or customer mapping.
|
||||||
|
- Ensured changes are limited to configuration foundations only, keeping Phase 1 scope intact.
|
||||||
|
|
||||||
|
## v20260115-02-autotask-settings-migration-fix
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed Autotask system settings migration so it is always executed during application startup.
|
||||||
|
- Added safe, idempotent column existence checks to prevent startup failures on re-deployments.
|
||||||
|
- Ensured all Autotask-related system_settings columns are created before being queried.
|
||||||
|
- Prevented aborted database transactions caused by missing columns during settings initialization.
|
||||||
|
- Improved overall stability of the Settings page when Autotask integration is enabled.
|
||||||
|
|
||||||
|
## v20260115-03-autotask-settings-ui
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Added visible Autotask configuration section under Settings → Integrations.
|
||||||
|
- Implemented form fields for enabling Autotask integration, environment selection, API credentials, tracking identifier, and Backupchecks base URL.
|
||||||
|
- Wired Autotask settings to SystemSettings for loading and saving configuration values.
|
||||||
|
- Added Diagnostics & Reference Data section with actions for testing the Autotask connection and refreshing reference data.
|
||||||
|
- Kept all functionality strictly within Phase 1 scope without introducing ticket or customer logic.
|
||||||
|
|
||||||
|
## v20260115-04-autotask-reference-data-fix
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed Autotask API client to use correct endpoints for reference data instead of invalid `/query` routes.
|
||||||
|
- Implemented proper retrieval of Autotask Queues and Ticket Sources via collection endpoints.
|
||||||
|
- Added dynamic retrieval of Autotask Priorities using ticket entity metadata and picklist values.
|
||||||
|
- Cached queues, ticket sources, and priorities in system settings for safe reuse in the UI.
|
||||||
|
- Updated Autotask settings UI to use dropdowns backed by live Autotask reference data.
|
||||||
|
- Improved “Test connection” to validate authentication and reference data access reliably.
|
||||||
|
- Fixed admin event logging to prevent secondary exceptions during error handling.
|
||||||
|
|
||||||
|
## v20260115-05-autotask-queues-picklist-fix
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Reworked Autotask reference data retrieval to use Ticket entity picklists instead of non-existent top-level resources.
|
||||||
|
- Retrieved Queues via the Tickets.queueID picklist to ensure compatibility with all Autotask tenants.
|
||||||
|
- Retrieved Ticket Sources via the Tickets.source picklist instead of a direct collection endpoint.
|
||||||
|
- Kept Priority retrieval fully dynamic using the Tickets.priority picklist.
|
||||||
|
- Normalized picklist values so IDs and display labels are handled consistently in settings dropdowns.
|
||||||
|
- Fixed Autotask connection test to rely on picklist availability, preventing false 404 errors.
|
||||||
|
|
||||||
|
## v20260115-06-autotask-auth-fallback
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Improved Autotask authentication handling to support sandbox-specific behavior.
|
||||||
|
- Implemented automatic fallback authentication flow when initial Basic Auth returns HTTP 401.
|
||||||
|
- Added support for header-based authentication using UserName and Secret headers alongside the Integration Code.
|
||||||
|
- Extended authentication error diagnostics to include selected environment and resolved Autotask zone information.
|
||||||
|
- Increased reliability of Autotask connection testing across different tenants and sandbox configurations.
|
||||||
|
|
||||||
|
## v20260115-07-autotask-picklist-field-detect
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Improved detection of Autotask Ticket entity picklist fields to handle tenant-specific field naming.
|
||||||
|
- Added fallback matching logic based on field name and display label for picklist fields.
|
||||||
|
- Fixed queue picklist resolution when fields are not named exactly `queue` or `queueid`.
|
||||||
|
- Applied the same robust detection logic to ticket priority picklist retrieval.
|
||||||
|
- Prevented connection test failures caused by missing or differently named metadata fields.
|
||||||
|
|
||||||
|
## v20260115-08-autotask-entityinfo-fields-shape-fix
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed parsing of Autotask entityInformation responses to correctly read field metadata from the `fields` attribute.
|
||||||
|
- Extended metadata normalization to support different response shapes returned by Autotask.
|
||||||
|
- Improved picklist value handling to support both inline picklist values and URL-based retrieval.
|
||||||
|
- Resolved failures in queue, source, and priority picklist detection caused by empty or misparsed field metadata.
|
||||||
|
- Stabilized Autotask connection testing across sandbox environments with differing metadata formats.
|
||||||
|
|
||||||
|
## v20260115-09-autotask-customer-company-mapping
|
||||||
|
|
||||||
|
- Added explicit Autotask company mapping to customers using ID-based linkage.
|
||||||
|
- Extended customer data model with Autotask company ID, cached company name, mapping status, and last sync timestamp.
|
||||||
|
- Implemented Autotask company search and lookup endpoints for customer mapping.
|
||||||
|
- Added mapping status handling to detect renamed, missing, or invalid Autotask companies.
|
||||||
|
- Updated Customers UI to allow searching, selecting, refreshing, and clearing Autotask company mappings.
|
||||||
|
- Ensured mappings remain stable when Autotask company names change and block future ticket actions when mappings are invalid.
|
||||||
|
|
||||||
|
## v20260115-10-autotask-customers-settings-helper-fix
|
||||||
|
|
||||||
|
- Fixed /customers crash caused by missing _get_or_create_settings by removing reliance on shared star-imported helpers.
|
||||||
|
- Added a local SystemSettings get-or-create helper in customers routes to prevent runtime NameError in mixed/partial deployments.
|
||||||
|
- Added explicit imports for SystemSettings, db, and datetime to keep the Customers page stable across versions.
|
||||||
|
|
||||||
|
## v20260115-11-autotask-companyname-unwrap
|
||||||
|
|
||||||
|
- Fixed Autotask company name being shown as "Unknown" by correctly unwrapping nested Autotask API responses.
|
||||||
|
- Improved company lookup handling to support different response shapes (single item and collection wrappers).
|
||||||
|
- Ensured the cached Autotask company name is stored and displayed consistently after mapping and refresh.
|
||||||
|
|
||||||
|
## v20260115-12-autotask-customers-refreshall-mappings
|
||||||
|
|
||||||
|
- Added a “Refresh all Autotask mappings” button on the Customers page to validate all mapped customers in one action.
|
||||||
|
- Implemented a new backend endpoint to refresh mapping status for all customers with an Autotask Company ID and return a status summary (ok/renamed/missing/invalid).
|
||||||
|
- Updated the Customers UI to call the refresh-all endpoint, show a short result summary, and reload to reflect updated mapping states.
|
||||||
|
|
||||||
|
## v20260115-14-autotask-runchecks-ticket-migration-fix
|
||||||
|
|
||||||
|
- Fixed missing database helper used by the Autotask ticket fields migration for job runs.
|
||||||
|
- Corrected the job_runs migration to ensure Autotask ticket columns are created reliably and committed properly.
|
||||||
|
- Resolved Run Checks errors caused by incomplete database migrations after introducing Autotask ticket support.
|
||||||
|
|
||||||
|
## v20260115-15-autotask-default-ticket-status-setting
|
||||||
|
- Added “Default Ticket Status” dropdown to Autotask settings (Ticket defaults).
|
||||||
|
- Implemented retrieval and caching of Autotask ticket statuses as reference data for dropdown usage.
|
||||||
|
- Extended reference data refresh to include Ticket Statuses and updated diagnostics counters accordingly.
|
||||||
|
- Added database column for cached ticket statuses and included it in migrations for existing installations.
|
||||||
|
|
||||||
|
## v20260115-16-autotask-ticket-create-response-fix
|
||||||
|
- Fixed Autotask ticket creation handling for tenants that return a lightweight or empty POST /Tickets response.
|
||||||
|
- Added support for extracting the created ticket ID from itemId/id fields and from the Location header.
|
||||||
|
- Added a follow-up GET /Tickets/{id} to always retrieve the full created ticket object (ensuring ticketNumber/id are available).
|
||||||
|
|
||||||
|
## v20260115-17-autotask-ticket-create-trackingid-lookup
|
||||||
|
- Reworked Autotask ticket creation flow to no longer rely on POST /Tickets response data for returning an ID.
|
||||||
|
- Added deterministic fallback lookup using Tickets/query filtered by TrackingIdentifier (and CompanyID when available).
|
||||||
|
- Ensured the created ticket is reliably retrieved via follow-up GET /Tickets/{id} so ticketNumber/id can always be stored.
|
||||||
|
- Eliminated false-negative ticket creation errors when Autotask returns an empty body and no Location header.
|
||||||
|
|
||||||
|
## v20260115-19-autotask-ticket-create-debug-logging
|
||||||
|
- Added optional verbose Autotask ticket creation logging (guarded by BACKUPCHECKS_AUTOTASK_DEBUG=1).
|
||||||
|
- Introduced per-request correlation IDs and included them in ticket creation error messages for log tracing.
|
||||||
|
- Logged POST /Tickets response characteristics (status, headers, body preview) to diagnose tenants returning incomplete create responses.
|
||||||
|
- Logged fallback Tickets/query lookup payload and result shape to pinpoint why deterministic lookup fails.
|
||||||
|
|
||||||
|
## v20260116-01-autotask-ticket-id-normalization
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Normalized Autotask GET /Tickets/{id} API responses by unwrapping the returned "item" object.
|
||||||
|
- Ensured the ticket data is returned as a flat object so existing logic can reliably read the ticket id.
|
||||||
|
- Enabled correct retrieval of the Autotask ticketNumber via a follow-up GET after ticket creation.
|
||||||
|
- Prevented false error messages where ticket creation succeeded but no ticket id was detected.
|
||||||
|
|
||||||
|
## v20260116-02-runchecks-autotask-create-refresh
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed a JavaScript error in the Run Checks view where a non-existent renderModal() function was called after creating an Autotask ticket.
|
||||||
|
- Replaced the renderModal() call with renderRun() to properly refresh the Run Checks modal state.
|
||||||
|
- Ensured the Autotask ticket status is updated in the UI without throwing a frontend error.
|
||||||
|
|
||||||
|
## v20260116-03-autotask-ticket-linking-visibility
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Ensured Autotask tickets created via Run Checks are stored as internal Ticket records instead of only external references.
|
||||||
|
- Linked created Autotask tickets to the corresponding Job Run so they appear in Tickets/Remarks.
|
||||||
|
- Added proper ticket association to Job Details, matching the behaviour of manually entered tickets.
|
||||||
|
- Updated the Run Checks view to show the ticket indicator when an Autotask ticket is linked to a run.
|
||||||
|
|
||||||
|
## v20260116-04-runchecks-autotask-ticket-polling
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Added read-only Autotask ticket polling triggered on Run Checks page load
|
||||||
|
- Introduced backend endpoint to poll only relevant active Autotask tickets linked to visible runs
|
||||||
|
- Implemented ticket ID deduplication to minimize Autotask API calls
|
||||||
|
- Ensured polling is best-effort and does not block Run Checks rendering
|
||||||
|
- Added client support for bulk ticket queries with per-ticket fallback
|
||||||
|
- Updated Run Checks UI to display polled PSA ticket status without modifying run state
|
||||||
|
- Explicitly prevented any ticket mutation, resolution, or Backupchecks state changes
|
||||||
|
|
||||||
|
## v20260116-05-autotask-ticket-create-link-all-open-runs
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed Autotask ticket creation to link the newly created ticket to all relevant open runs of the same job
|
||||||
|
- Aligned automatic ticket creation behaviour with existing manual ticket linking logic
|
||||||
|
- Ensured ticket linkage is applied consistently across runs until the ticket is resolved
|
||||||
|
- Prevented Phase 2.1 polling from being blocked by incomplete ticket-run associations
|
||||||
|
- No changes made to polling logic, resolution logic, or PSA state interpretation
|
||||||
|
|
||||||
|
## v20260116-06-runchecks-polling-merge-fix
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Restored Phase 2.1 read-only Autotask polling logic after ticket-creation fix overwrote Run Checks routes
|
||||||
|
- Merged polling endpoint and UI polling trigger with updated ticket-linking behaviour
|
||||||
|
- Ensured polled PSA ticket status is available again on the Run Checks page
|
||||||
|
- No changes made to ticket creation logic, resolution handling, or Backupchecks run state
|
||||||
|
|
||||||
|
|
||||||
|
## v20260116-07-autotask-ticket-link-all-runs-ticketjobrun-fix
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed Autotask ticket creation linking so the internal TicketJobRun associations are created for all relevant open runs of the same job
|
||||||
|
- Ensured ticket numbers and ticket presence are consistently visible per run (Run Checks and Job Details), not only for the selected run
|
||||||
|
- Made the list of runs to link deterministic by collecting run IDs first, then applying both run field updates and internal ticket linking across that stable set
|
||||||
|
- No changes made to polling logic or PSA status interpretation
|
||||||
|
|
||||||
|
## v20260116-08-autotask-ticket-backfill-ticketjobrun
|
||||||
|
|
||||||
|
- Fixed inconsistent ticket linking when creating Autotask tickets from the Run Checks page.
|
||||||
|
- Ensured that newly created Autotask tickets are linked to all related job runs, not only the selected run.
|
||||||
|
- Backfilled ticket-to-run associations so tickets appear correctly in the Tickets overview.
|
||||||
|
- Corrected Job Details visibility so open runs linked to the same ticket now display the ticket number consistently.
|
||||||
|
- Aligned Run Checks, Tickets, and Job Details views to use the same ticket-jobrun linkage logic.
|
||||||
|
|
||||||
|
## v20260116-09-autotask-ticket-propagate-active-runs
|
||||||
|
|
||||||
|
- Updated ticket propagation logic so Autotask tickets are linked to all active job runs (non-Reviewed) visible on the Run Checks page.
|
||||||
|
- Ensured ticket remarks and ticket-jobrun entries are created for each active run, not only the initially selected run.
|
||||||
|
- Implemented automatic ticket inheritance for newly incoming runs of the same job while the ticket remains unresolved.
|
||||||
|
- Stopped ticket propagation once the ticket or job is marked as Resolved to prevent incorrect linking to closed incidents.
|
||||||
|
- Aligned Run Checks, Tickets overview, and Job Details to consistently reflect ticket presence across all active runs.
|
||||||
|
|
||||||
|
## v20260116-10-autotask-ticket-sync-internal-ticketjobrun
|
||||||
|
|
||||||
|
- Aligned Autotask ticket creation with the legacy manual ticket workflow by creating or updating an internal Ticket record using the Autotask ticket number.
|
||||||
|
- Ensured a one-to-one mapping between Autotask tickets and internal Backupchecks tickets.
|
||||||
|
- Linked the internal Ticket to all active (non-Reviewed) job runs by creating or backfilling TicketJobRun relations.
|
||||||
|
- Restored visibility of Autotask-created tickets in Tickets, Tickets/Remarks, and Job Details pages.
|
||||||
|
- Implemented idempotent behavior so repeated ticket creation or re-polling does not create duplicate tickets or links.
|
||||||
|
- Prepared the ticket model for future scenarios where Autotask integration can be disabled and tickets can be managed manually again.
|
||||||
|
|
||||||
|
## v20260116-11-autotask-ticket-sync-legacy
|
||||||
|
|
||||||
|
- Restored legacy internal ticket workflow for Autotask-created tickets by ensuring internal Ticket records are created when missing.
|
||||||
|
- Implemented automatic creation and linking of TicketJobRun records for all active job_runs (reviewed_at IS NULL) that already contain Autotask ticket data.
|
||||||
|
- Ensured 1:1 mapping between an Autotask ticket and a single internal Ticket, identical to manual ticket behavior.
|
||||||
|
- Added inheritance logic so newly created job_runs automatically link to an existing open internal Ticket until it is resolved.
|
||||||
|
- Aligned Autotask ticket creation and polling paths with the legacy manual ticket creation flow, without changing any UI behavior.
|
||||||
|
- Ensured solution works consistently with Autotask integration enabled or disabled by relying exclusively on internal Ticket and TicketJobRun structures.
|
||||||
|
|
||||||
|
## v20260119-04-autotask-ticket-registration
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Implemented reliable Autotask ticket number retrieval by enforcing a post-create GET on the created ticket, avoiding incomplete create responses.
|
||||||
|
- Added automatic creation or reuse of an internal Ticket based on the Autotask ticket number to preserve legacy ticket behavior.
|
||||||
|
- Ensured idempotent linking of the internal Ticket to all open job runs (reviewed_at IS NULL) for the same job, matching manual ticket functionality.
|
||||||
|
- Propagated Autotask ticket references (autotask_ticket_id and autotask_ticket_number) to all related open runs when a ticket is created.
|
||||||
|
- Added repair/propagation logic so runs that already have an Autotask ticket ID but lack internal linking are corrected automatically.
|
||||||
|
- Guaranteed that future runs for the same job inherit the existing Autotask and internal ticket associations.
|
||||||
|
|
||||||
|
## v20260119-05-autotask-create-itemid
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Updated Autotask ticket creation handling to treat a POST response containing only {"itemId": <id>} as a successful ticket creation.
|
||||||
|
- Normalized the create response so the returned itemId is mapped internally to a ticket id, ensuring the existing follow-up GET /Tickets/{id} flow is always executed.
|
||||||
|
- Fixed erroneous failure condition where ticket creation was rejected because Autotask did not return a full ticket object.
|
||||||
|
- Restored compatibility with Autotask’s documented behavior for ticket creation responses.
|
||||||
|
|
||||||
|
## v20260119-06-runchecks-renderRun-fix
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed JavaScript error in Run Checks where a non-existent renderModal() function was called after creating an Autotask ticket.
|
||||||
|
- Replaced the invalid renderModal() call with renderRun() to correctly refresh the run state and UI.
|
||||||
|
- Prevented UI failure after successful Autotask ticket creation while preserving backend behavior.
|
||||||
|
|
||||||
|
## v20260119-07-autotask-propagate-ticket-to-all-runs
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed ticket propagation logic so Autotask ticket numbers are applied to all open runs (reviewed_at IS NULL), not only the most recent run.
|
||||||
|
- Ensured runs that already had an autotask_ticket_id but were missing the autotask_ticket_number are now correctly updated.
|
||||||
|
- Restored legacy behavior where all active runs for the same job consistently display the linked ticket in Tickets, Tickets/Remarks, and Job Details.
|
||||||
|
- Prevented partial ticket linkage that caused only the newest run to show the ticket number.
|
||||||
|
|
||||||
|
## v20260119-08-autotask-disable-toggle-persist
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed persistence of the “Enable Autotask integration” setting so disabling the integration is correctly saved.
|
||||||
|
- Updated form handling to explicitly set the Autotask enabled flag when the checkbox is unchecked, instead of implicitly keeping the previous value.
|
||||||
|
- Prevented the Autotask integration from being automatically re-enabled after saving settings.
|
||||||
|
|
||||||
|
## v20260119-09-autotask-disabled-legacy-ticket-ui
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Restored the legacy manual ticket registration UI when the Autotask integration is disabled.
|
||||||
|
- Updated Run Checks to switch the ticket creation interface based solely on the autotask_enabled setting.
|
||||||
|
- Hidden the Autotask ticket creation section entirely when the integration is turned off.
|
||||||
|
- Re-enabled the original legacy ticket creation flow to allow correct Ticket and TicketJobRun linking without Autotask.
|
||||||
|
|
||||||
|
|
||||||
|
## v20260119-10-runchecks-renderRun-alias
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed remaining JavaScript references to the non-existent renderModal() function in the Run Checks flow.
|
||||||
|
- Ensured consistent use of renderRun() when toggling the Autotask integration on and off.
|
||||||
|
- Prevented UI errors when re-enabling the Autotask integration after it was disabled.
|
||||||
|
|
||||||
|
## v20260119-03-autotask-ticket-state-sync
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Implemented Phase 2: read-only PSA-driven ticket state synchronisation.
|
||||||
|
- Added targeted polling on Run Checks load for runs with an Autotask Ticket ID and no reviewed_at timestamp.
|
||||||
|
- Introduced authoritative fallback logic using GET Tickets/{TicketID} when tickets are missing from active list queries.
|
||||||
|
- Mapped Autotask status ID 5 (Completed) to automatic resolution of all linked active runs.
|
||||||
|
- Marked resolved runs explicitly as "Resolved by PSA" without modifying Autotask data.
|
||||||
|
- Ensured multi-run consistency: one Autotask ticket correctly resolves all associated active job runs.
|
||||||
|
- Preserved internal Ticket and TicketJobRun integrity to maintain legacy Tickets, Remarks, and Job Details behaviour.
|
||||||
|
|
||||||
|
## v20260119-04-autotask-psa-resolved-ui-recreate-ticket
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Added explicit UI indication when an Autotask ticket is resolved by PSA ("Resolved by PSA (Autotask)").
|
||||||
|
- Differentiated resolution origin between PSA-driven resolution and Backupchecks-driven resolution.
|
||||||
|
- Re-enabled ticket creation when an existing Autotask ticket was resolved by PSA, allowing operators to create a new ticket if the previous one was closed incorrectly.
|
||||||
|
- Updated Autotask ticket panel to reflect resolved state without blocking further actions.
|
||||||
|
- Extended backend validation to allow ticket re-creation after PSA-resolved tickets while preserving historical ticket links.
|
||||||
|
- Ensured legacy Tickets, Remarks, and Job Details behaviour remains intact.
|
||||||
|
|
||||||
|
## v20260119-14-fix-routes-runchecks-syntax
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed a Python SyntaxError in routes_run_checks.py caused by an unmatched closing parenthesis.
|
||||||
|
- Removed an extra closing bracket introduced during the Autotask PSA resolved / recreate ticket changes.
|
||||||
|
- Restored successful Gunicorn worker startup and backend application boot.
|
||||||
|
- No functional or behavioural changes beyond resolving the syntax error.
|
||||||
|
|
||||||
|
## v20260119-15-fix-migrations-autotask-phase2
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Restored the missing `_get_table_columns()` helper function required by multiple database migrations.
|
||||||
|
- Fixed Autotask-related migrations that introduced the `resolved_origin` and Autotask job_run fields.
|
||||||
|
- Ensured all migrations run inside a safe transaction context so failures always trigger a rollback.
|
||||||
|
- Prevented database sessions from remaining in an aborted state after a failed migration.
|
||||||
|
- Resolved runtime database errors on the Run Checks page caused by earlier migration failures.
|
||||||
|
|
||||||
|
## v20260119-16-fix-runchecks-render-modal
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed a JavaScript runtime error on the Run Checks page where `renderModal` was referenced but not defined.
|
||||||
|
- Replaced the obsolete `renderModal(...)` call with the correct Run Checks rendering function.
|
||||||
|
- Restored proper Run Checks page rendering without breaking existing ticket or modal behaviour.
|
||||||
|
|
||||||
|
## v20260119-17-fix-autotask-postcreate-ticketnumber-internal-linking
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Enforced mandatory post-create retrieval (GET Tickets/{TicketID}) after Autotask ticket creation to reliably obtain the Ticket Number.
|
||||||
|
- Persisted the retrieved Ticket Number to all active (unreviewed) runs of the same job when missing.
|
||||||
|
- Restored automatic creation and repair of internal Ticket records once the Ticket Number is known.
|
||||||
|
- Restored TicketJobRun linking so Autotask-created tickets appear correctly in Tickets, Remarks, and Job Details.
|
||||||
|
- Prevented UI state where a ticket was shown as “created” without a Ticket Number or internal ticket linkage.
|
||||||
|
|
||||||
|
## v20260119-18-fix-legacy-ticketnumber-sync
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Restored legacy ticket number compatibility by aligning internal Ticket activation timing with the original run date.
|
||||||
|
- Set internal Ticket `active_from_date` based on the earliest associated run timestamp instead of the current date.
|
||||||
|
- Ensured legacy ticket visibility and numbering work correctly for historical runs across Tickets, Remarks, Job Details, and Run Checks indicators.
|
||||||
|
- Applied the same logic during post-create processing and Phase 2 polling repair to keep legacy behaviour consistent and idempotent.
|
||||||
|
|
||||||
|
## v20260120-01-autotask-deleted-ticket-detection
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Added detection of deleted Autotask tickets using DeletedTicketLogs.
|
||||||
|
- Implemented fallback deleted detection via GET /Tickets/{id} when DeletedTicketLogs is unavailable.
|
||||||
|
- Stored deleted ticket metadata on job runs:
|
||||||
|
- autotask_ticket_deleted_at
|
||||||
|
- autotask_ticket_deleted_by_resource_id
|
||||||
|
- Marked internal tickets as resolved when the linked Autotask ticket is deleted (audit-safe handling).
|
||||||
|
- Updated Run Checks to display “Deleted in PSA” status.
|
||||||
|
- No changes made to Job Details view.
|
||||||
|
|
||||||
|
## v20260120-01-autotask-deleted-ticket-audit
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Extended deleted ticket audit data by resolving deletedByResourceID to resource details.
|
||||||
|
- Stored additional audit fields on job runs:
|
||||||
|
- autotask_ticket_deleted_by_first_name
|
||||||
|
- autotask_ticket_deleted_by_last_name
|
||||||
|
- Persisted deletion date and time from Autotask DeletedTicketLogs.
|
||||||
|
- Updated Run Checks to display:
|
||||||
|
- Deleted at (date/time)
|
||||||
|
- Deleted by (first name + last name, with resource ID as fallback)
|
||||||
|
- Ensured resource lookup is executed only when a delete is detected to minimize API usage.
|
||||||
|
- No changes made to Job Details view; data is stored for future reporting use.
|
||||||
|
|
||||||
|
## v20260120-03-autotask-deletedby-name-runlink
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Extended deleted ticket audit handling by resolving DeletedByResourceID to resource details.
|
||||||
|
- Stored deleted-by audit information on job runs:
|
||||||
|
- autotask_ticket_deleted_by_first_name
|
||||||
|
- autotask_ticket_deleted_by_last_name
|
||||||
|
- Updated Run Checks UI to display:
|
||||||
|
- “Deleted by: <First name> <Last name>”
|
||||||
|
- Fallback to “Deleted by resource ID” when name data is unavailable.
|
||||||
|
- Ensured deletion date/time continues to be shown in Run Checks.
|
||||||
|
- Restored legacy ticket behavior by automatically linking new job runs to existing internal tickets (TicketJobRun).
|
||||||
|
- Ensured Autotask-linked tickets are inherited by new runs when an open ticket already exists for the job.
|
||||||
|
- No changes made to Job Details view; audit data is stored for future reporting.
|
||||||
|
|
||||||
|
## v20260120-04-autotask-deletedby-name-runlink-fix
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed an IndentationError in mail_importer.py that prevented the application from booting.
|
||||||
|
- Added idempotent database migration for deleted-by name audit fields on job_runs:
|
||||||
|
- autotask_ticket_deleted_by_first_name
|
||||||
|
- autotask_ticket_deleted_by_last_name
|
||||||
|
- Extended Autotask client with GET /Resources/{id} support to resolve deletedByResourceID.
|
||||||
|
- Persisted deleted-by first/last name on job runs when a DeletedTicketLogs entry is detected.
|
||||||
|
- Updated Run Checks to display “Deleted by: <First name> <Last name>” with resource ID as fallback.
|
||||||
|
- Restored legacy behavior by linking newly created job runs to any open internal tickets (TicketJobRun inherit) during mail import.
|
||||||
|
|
||||||
|
## v20260120-05-autotask-indent-fix
|
||||||
|
|
||||||
|
- Fixed an IndentationError in routes_inbox.py that prevented Gunicorn from starting.
|
||||||
|
- Corrected the indentation of db.session.flush() to restore valid Python syntax.
|
||||||
|
- No functional or logical changes were made.
|
||||||
|
|
||||||
|
## v20260120-06-routes-inbox-indent-fix
|
||||||
|
|
||||||
|
### Changes:
|
||||||
|
- Fixed multiple indentation and syntax errors in routes_inbox.py.
|
||||||
|
- Corrected misaligned db.session.flush() calls to ensure proper transaction handling.
|
||||||
|
- Repaired indentation of link_open_internal_tickets_to_run logic to prevent runtime exceptions.
|
||||||
|
- Restored application startup stability by resolving Python IndentationError issues.
|
||||||
|
|
||||||
|
## v20260120-07-autotask-psa-resolution-handling
|
||||||
|
|
||||||
|
- Added support for linking existing Autotask tickets (Phase 2.2) using Autotask REST queries.
|
||||||
|
- Implemented ticket listing by company with exclusion of terminal tickets (status != Complete).
|
||||||
|
- Added search support for existing tickets by exact ticketNumber and by title (contains).
|
||||||
|
- Implemented authoritative validation of selected Autotask tickets via GET /Tickets/{id}.
|
||||||
|
- Defined terminal ticket detection based on:
|
||||||
|
- status == Complete (5)
|
||||||
|
- OR completedDate is set
|
||||||
|
- OR resolvedDateTime is set.
|
||||||
|
- Ensured terminal Autotask tickets automatically resolve the corresponding internal Backupchecks ticket.
|
||||||
|
- Preserved legacy internal Ticket and TicketJobRun creation/linking so Tickets overview, Tickets/Remarks, and Job Details continue to function identically to manually linked tickets.
|
||||||
|
- Ensured resolution timestamps are derived from Autotask (resolvedDateTime / completedDate) instead of using current time.
|
||||||
|
|
||||||
|
## v20260120-08-runchecks-link-existing-autotask-ticket
|
||||||
|
|
||||||
|
- Added Phase 2.2 “Link existing Autotask ticket” flow in Run Checks:
|
||||||
|
- New UI button “Link existing” next to “Create” in the Run Checks modal.
|
||||||
|
- Added modal popup with search + refresh and a selectable list of non-terminal tickets.
|
||||||
|
- Added backend API endpoints:
|
||||||
|
- GET /api/run-checks/autotask-existing-tickets (list/search tickets for mapped company, excluding terminal statuses)
|
||||||
|
- POST /api/run-checks/autotask-link-existing-ticket (link selected ticket to run and create/update internal Ticket + TicketJobRun links)
|
||||||
|
- Extended Autotask client with a ticket query helper to support listing/searching tickets for a company.
|
||||||
|
- Improved internal ticket resolve handling:
|
||||||
|
- Do not overwrite resolved_origin when already set; keep “psa” origin when resolved by PSA.
|
||||||
|
|
||||||
|
## v20260120-09-runchecks-modal-sequence-fix
|
||||||
|
|
||||||
|
- Fixed Run Checks popup behavior by preventing stacked Bootstrap modals.
|
||||||
|
- Restored correct modal sequence:
|
||||||
|
- The standard Run Checks modal opens first as before.
|
||||||
|
- The Autotask popup is opened only after explicitly selecting an Autotask action.
|
||||||
|
- Ensured the Run Checks modal is temporarily hidden when the Autotask popup opens.
|
||||||
|
- Automatically reopens the Run Checks modal when the Autotask popup is closed.
|
||||||
|
- Prevented broken backdrops, focus loss, and non-responsive popups caused by multiple active modals.
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user