Compare commits

...

20 Commits

Author SHA1 Message Date
abb6780744 Auto-commit local changes before build (2026-01-16 09:04:12) 2026-01-16 09:04:12 +01:00
83a29a7a3c Auto-commit local changes before build (2026-01-15 16:31:32) 2026-01-15 16:31:32 +01:00
66f5a57fe0 Auto-commit local changes before build (2026-01-15 16:17:26) 2026-01-15 16:17:26 +01:00
473044bd67 Auto-commit local changes before build (2026-01-15 16:02:52) 2026-01-15 16:02:52 +01:00
afd45cc568 Auto-commit local changes before build (2026-01-15 15:19:37) 2026-01-15 15:19:37 +01:00
3564bcf62f Auto-commit local changes before build (2026-01-15 15:05:42) 2026-01-15 15:05:42 +01:00
49fd29a6f2 Auto-commit local changes before build (2026-01-15 14:36:50) 2026-01-15 14:36:50 +01:00
49f6d41715 Auto-commit local changes before build (2026-01-15 14:24:54) 2026-01-15 14:24:54 +01:00
186807b098 Auto-commit local changes before build (2026-01-15 14:14:29) 2026-01-15 14:14:29 +01:00
c68b401709 Auto-commit local changes before build (2026-01-15 14:08:59) 2026-01-15 14:08:59 +01:00
5b9b6f4c38 Auto-commit local changes before build (2026-01-15 13:45:53) 2026-01-15 13:45:53 +01:00
981d65c274 Auto-commit local changes before build (2026-01-15 12:44:01) 2026-01-15 12:44:01 +01:00
1a2ca59d16 Auto-commit local changes before build (2026-01-15 12:31:08) 2026-01-15 12:31:08 +01:00
83d487a206 Auto-commit local changes before build (2026-01-15 11:52:52) 2026-01-15 11:52:52 +01:00
490ab1ae34 Auto-commit local changes before build (2026-01-15 11:10:13) 2026-01-15 11:10:13 +01:00
1a64627a4e Auto-commit local changes before build (2026-01-15 10:40:40) 2026-01-15 10:40:40 +01:00
d5fdc9a8d9 Auto-commit local changes before build (2026-01-15 10:21:30) 2026-01-15 10:21:30 +01:00
f6310da575 Auto-commit local changes before build (2026-01-15 10:12:09) 2026-01-15 10:12:09 +01:00
48e7830957 Auto-commit local changes before build (2026-01-15 09:37:33) 2026-01-15 09:37:33 +01:00
777a9b4b31 Auto-commit local changes before build (2026-01-13 17:16:20) 2026-01-13 17:16:20 +01:00
14 changed files with 3026 additions and 46 deletions

View File

@ -1 +1 @@
v20260113-08-vspc-object-linking-normalize v20260115-19-autotask-ticket-create-debug-logging

View File

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

View File

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

View File

@ -4,7 +4,8 @@ import calendar
from datetime import date, datetime, time, timedelta, timezone from datetime import date, datetime, time, timedelta, timezone
from flask import jsonify, render_template, request from flask import jsonify, render_template, request, url_for
from urllib.parse import urljoin
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import and_, or_, func, text from sqlalchemy import and_, or_, func, text
@ -35,6 +36,106 @@ from ..models import (
User, User,
) )
def _build_autotask_client_from_settings():
"""Build an AutotaskClient from stored settings or raise a user-safe exception."""
settings = _get_or_create_settings()
if not getattr(settings, "autotask_enabled", False):
raise RuntimeError("Autotask integration is disabled.")
required = [
getattr(settings, "autotask_environment", None),
getattr(settings, "autotask_api_username", None),
getattr(settings, "autotask_api_password", None),
getattr(settings, "autotask_tracking_identifier", None),
]
if any(not (x and str(x).strip()) for x in required):
raise RuntimeError("Autotask settings incomplete.")
from ..integrations.autotask.client import AutotaskClient
return AutotaskClient(
username=settings.autotask_api_username,
password=settings.autotask_api_password,
api_integration_code=settings.autotask_tracking_identifier,
environment=settings.autotask_environment,
)
def _determine_autotask_severity(status_text: str | None) -> str:
s = (status_text or "").strip().lower()
if "warning" in s:
return "warning"
if "error" in s or "fail" in s:
return "error"
if "missed" in s:
return "error"
return "warning"
def _compose_autotask_ticket_description(
*,
settings,
job: Job,
run: JobRun,
status_display: str,
overall_message: str,
objects_payload: list[dict[str, str]],
) -> str:
tz_name = _get_ui_timezone_name() or "Europe/Amsterdam"
run_dt = run.run_at
run_at_str = _format_datetime(run_dt) if run_dt else "-"
base_url = (getattr(settings, "autotask_base_url", None) or "").strip()
job_rel = url_for("main.job_detail", job_id=job.id)
# Link to Job Details with a hint for the specific run.
job_link = urljoin(base_url.rstrip("/") + "/", job_rel.lstrip("/"))
if run.id:
job_link = f"{job_link}?run_id={int(run.id)}"
lines: list[str] = []
lines.append(f"Customer: {job.customer.name if job.customer else ''}")
lines.append(f"Job: {job.job_name or ''}")
lines.append(f"Backup: {job.backup_software or ''} / {job.backup_type or ''}")
lines.append(f"Run at ({tz_name}): {run_at_str}")
lines.append(f"Status: {status_display or ''}")
lines.append("")
overall_message = (overall_message or "").strip()
if overall_message:
lines.append("Summary:")
lines.append(overall_message)
lines.append("")
lines.append("Multiple objects reported messages. See Backupchecks for full details.")
else:
# Fallback to object-level messages with a hard limit.
limit = 10
shown = 0
total = 0
for o in objects_payload or []:
name = (o.get("name") or "").strip()
err = (o.get("error_message") or "").strip()
st = (o.get("status") or "").strip()
if not name:
continue
if not err and not st:
continue
total += 1
if shown >= limit:
continue
msg = err or st
lines.append(f"- {name}: {msg}")
shown += 1
if total == 0:
lines.append("No detailed object messages available. See Backupchecks for full details.")
elif total > shown:
lines.append(f"And {int(total - shown)} additional objects reported similar messages.")
lines.append("")
lines.append(f"Backupchecks details: {job_link}")
return "\n".join(lines).strip() + "\n"
# Grace window for matching real runs to an expected schedule slot. # Grace window for matching real runs to an expected schedule slot.
# A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot. # A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot.
MISSED_GRACE_WINDOW = timedelta(hours=1) MISSED_GRACE_WINDOW = timedelta(hours=1)
@ -753,6 +854,8 @@ def run_checks_details():
"mail": mail_meta, "mail": mail_meta,
"body_html": body_html, "body_html": body_html,
"objects": objects_payload, "objects": objects_payload,
"autotask_ticket_id": getattr(run, "autotask_ticket_id", None),
"autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "",
} }
) )
@ -770,6 +873,175 @@ def run_checks_details():
return jsonify({"status": "ok", "job": job_payload, "runs": runs_payload}) return jsonify({"status": "ok", "job": job_payload, "runs": runs_payload})
@main_bp.post("/api/run-checks/autotask-ticket")
@login_required
@roles_required("admin", "operator")
def api_run_checks_create_autotask_ticket():
"""Create an Autotask ticket for a specific run.
Enforces: exactly one ticket per run.
"""
data = request.get_json(silent=True) or {}
try:
run_id = int(data.get("run_id") or 0)
except Exception:
run_id = 0
if run_id <= 0:
return jsonify({"status": "error", "message": "Invalid parameters."}), 400
run = JobRun.query.get(run_id)
if not run:
return jsonify({"status": "error", "message": "Run not found."}), 404
# Idempotent: if already created, return existing linkage.
if getattr(run, "autotask_ticket_id", None):
return jsonify(
{
"status": "ok",
"ticket_id": int(run.autotask_ticket_id),
"ticket_number": getattr(run, "autotask_ticket_number", None) or "",
"already_exists": True,
}
)
job = Job.query.get(run.job_id)
if not job:
return jsonify({"status": "error", "message": "Job not found."}), 404
customer = Customer.query.get(job.customer_id) if getattr(job, "customer_id", None) else None
if not customer:
return jsonify({"status": "error", "message": "Customer not found."}), 404
if not getattr(customer, "autotask_company_id", None):
return jsonify({"status": "error", "message": "Customer has no Autotask company mapping."}), 400
if (getattr(customer, "autotask_mapping_status", None) or "").strip().lower() not in ("ok", "renamed"):
return jsonify({"status": "error", "message": "Autotask company mapping is not valid."}), 400
settings = _get_or_create_settings()
base_url = (getattr(settings, "autotask_base_url", None) or "").strip()
if not base_url:
return jsonify({"status": "error", "message": "Autotask Base URL is not configured."}), 400
# Required ticket defaults
if not getattr(settings, "autotask_default_queue_id", None):
return jsonify({"status": "error", "message": "Autotask default queue is not configured."}), 400
if not getattr(settings, "autotask_default_ticket_source_id", None):
return jsonify({"status": "error", "message": "Autotask default ticket source is not configured."}), 400
if not getattr(settings, "autotask_default_ticket_status", None):
return jsonify({"status": "error", "message": "Autotask default ticket status is not configured."}), 400
# Determine display status (including overrides) for consistent subject/priority mapping.
status_display = run.status or "-"
try:
status_display, _, _, _ov_id, _ov_reason = _apply_overrides_to_run(job, run)
except Exception:
status_display = run.status or "-"
severity = _determine_autotask_severity(status_display)
priority_id = None
if severity == "warning":
priority_id = getattr(settings, "autotask_priority_warning", None)
else:
priority_id = getattr(settings, "autotask_priority_error", None)
# Load mail + objects for ticket composition.
msg = MailMessage.query.get(run.mail_message_id) if run.mail_message_id else None
overall_message = (getattr(msg, "overall_message", None) or "") if msg else ""
objects_payload: list[dict[str, str]] = []
try:
objs = run.objects.order_by(JobObject.object_name.asc()).all()
except Exception:
objs = list(run.objects or [])
for o in objs or []:
objects_payload.append(
{
"name": getattr(o, "object_name", "") or "",
"type": getattr(o, "object_type", "") or "",
"status": getattr(o, "status", "") or "",
"error_message": getattr(o, "error_message", "") or "",
}
)
if (not objects_payload) and msg:
try:
mos = MailObject.query.filter_by(mail_message_id=msg.id).order_by(MailObject.object_name.asc()).all()
except Exception:
mos = []
for mo in mos or []:
objects_payload.append(
{
"name": getattr(mo, "object_name", "") or "",
"type": getattr(mo, "object_type", "") or "",
"status": getattr(mo, "status", "") or "",
"error_message": getattr(mo, "error_message", "") or "",
}
)
subject = f"[Backupchecks] {customer.name} - {job.job_name or ''} - {status_display}"
description = _compose_autotask_ticket_description(
settings=settings,
job=job,
run=run,
status_display=status_display,
overall_message=overall_message,
objects_payload=objects_payload,
)
payload = {
"companyID": int(customer.autotask_company_id),
"title": subject,
"description": description,
"queueID": int(settings.autotask_default_queue_id),
"source": int(settings.autotask_default_ticket_source_id),
"status": int(settings.autotask_default_ticket_status),
}
if priority_id:
payload["priority"] = int(priority_id)
try:
client = _build_autotask_client_from_settings()
created = client.create_ticket(payload)
except Exception as exc:
return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400
ticket_id = created.get("id") if isinstance(created, dict) else None
ticket_number = None
if isinstance(created, dict):
ticket_number = created.get("ticketNumber") or created.get("number") or created.get("ticket_number")
if not ticket_id:
return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400
try:
run.autotask_ticket_id = int(ticket_id)
except Exception:
run.autotask_ticket_id = None
run.autotask_ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None
run.autotask_ticket_created_at = datetime.utcnow()
run.autotask_ticket_created_by_user_id = current_user.id
try:
db.session.add(run)
db.session.commit()
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": f"Failed to store ticket reference: {exc}"}), 500
return jsonify(
{
"status": "ok",
"ticket_id": int(run.autotask_ticket_id) if run.autotask_ticket_id else None,
"ticket_number": run.autotask_ticket_number or "",
"already_exists": False,
}
)
@main_bp.post("/api/run-checks/mark-reviewed") @main_bp.post("/api/run-checks/mark-reviewed")
@login_required @login_required
@roles_required("admin", "operator") @roles_required("admin", "operator")

View File

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

View File

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

View File

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

View File

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

View File

@ -214,18 +214,16 @@
<div id="rcm_alerts" class="small"></div> <div id="rcm_alerts" class="small"></div>
<div class="mt-2"> <div class="mt-2">
<div class="row g-2 align-items-start"> <div class="row g-2 align-items-start">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="border rounded p-2"> <div class="border rounded p-2">
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
<div class="fw-semibold">New ticket</div> <div class="fw-semibold">Autotask ticket</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button> <button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button>
</div> </div>
<div class="mt-2"> <div class="mt-2 small" id="rcm_autotask_info"></div>
<input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" /> <div class="mt-2 small text-muted" id="rcm_autotask_status"></div>
</div> </div>
<div class="mt-2 small text-muted" id="rcm_ticket_status"></div> </div>
</div>
</div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="border rounded p-2"> <div class="border rounded p-2">
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
@ -841,56 +839,78 @@ table.addEventListener('change', function (e) {
} }
function bindInlineCreateForms() { function bindInlineCreateForms() {
var btnTicket = document.getElementById('rcm_ticket_save'); var btnAutotask = document.getElementById('rcm_autotask_create');
var atInfo = document.getElementById('rcm_autotask_info');
var atStatus = document.getElementById('rcm_autotask_status');
var btnRemark = document.getElementById('rcm_remark_save'); var btnRemark = document.getElementById('rcm_remark_save');
var tCode = document.getElementById('rcm_ticket_code');
var tStatus = document.getElementById('rcm_ticket_status');
var rBody = document.getElementById('rcm_remark_body'); var rBody = document.getElementById('rcm_remark_body');
var rStatus = document.getElementById('rcm_remark_status'); var rStatus = document.getElementById('rcm_remark_status');
function clearStatus() { function clearStatus() {
if (tStatus) tStatus.textContent = ''; if (atStatus) atStatus.textContent = '';
if (rStatus) rStatus.textContent = ''; if (rStatus) rStatus.textContent = '';
} }
function setDisabled(disabled) { function setDisabled(disabled) {
if (btnTicket) btnTicket.disabled = disabled; if (btnAutotask) btnAutotask.disabled = disabled;
if (btnRemark) btnRemark.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled;
if (tCode) tCode.disabled = disabled; if (rBody) rBody.disabled = disabled;
if (rBody) rBody.disabled = disabled;
} }
window.__rcmSetCreateDisabled = setDisabled; window.__rcmSetCreateDisabled = setDisabled;
window.__rcmClearCreateStatus = clearStatus; window.__rcmClearCreateStatus = clearStatus;
if (btnTicket) { function renderAutotaskInfo(run) {
btnTicket.addEventListener('click', function () { if (!atInfo) return;
var num = (run && run.autotask_ticket_number) ? String(run.autotask_ticket_number) : '';
if (num) {
atInfo.innerHTML = '<div><strong>Ticket:</strong> ' + escapeHtml(num) + '</div>';
} else if (run && run.autotask_ticket_id) {
atInfo.innerHTML = '<div><strong>Ticket:</strong> created</div>';
} else {
atInfo.innerHTML = '<div class="text-muted">No Autotask ticket created for this run.</div>';
}
}
window.__rcmRenderAutotaskInfo = renderAutotaskInfo;
if (btnAutotask) {
btnAutotask.addEventListener('click', function () {
if (!currentRunId) { alert('Select a run first.'); return; } if (!currentRunId) { alert('Select a run first.'); return; }
clearStatus(); clearStatus();
var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; if (atStatus) atStatus.textContent = 'Creating ticket...';
if (!ticket_code) { btnAutotask.disabled = true;
if (tStatus) tStatus.textContent = 'Ticket number is required.'; apiJson('/api/run-checks/autotask-ticket', {
else alert('Ticket number is required.');
return;
}
if (!/^T\d{8}\.\d{4}$/.test(ticket_code)) {
if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.';
else alert('Invalid ticket number format. Expected TYYYYMMDD.####.');
return;
}
if (tStatus) tStatus.textContent = 'Saving...';
apiJson('/api/tickets', {
method: 'POST', method: 'POST',
body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code}) body: JSON.stringify({run_id: currentRunId})
}) })
.then(function () { .then(function (j) {
if (tCode) tCode.value = ''; if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
if (tStatus) tStatus.textContent = ''; if (atStatus) atStatus.textContent = '';
loadAlerts(currentRunId);
// Refresh modal data so UI reflects stored ticket linkage.
var keepRunId = currentRunId;
if (currentJobId) {
return fetch('/api/run-checks/details?job_id=' + encodeURIComponent(currentJobId))
.then(function (r) { return r.json(); })
.then(function (payload) {
currentPayload = payload;
// Find the same run index
var idx = 0;
var runs = (payload && payload.runs) || [];
for (var i = 0; i < runs.length; i++) {
if (String(runs[i].id) === String(keepRunId)) { idx = i; break; }
}
renderModal(payload, idx);
});
}
}) })
.catch(function (e) { .catch(function (e) {
if (tStatus) tStatus.textContent = e.message || 'Failed.'; if (atStatus) atStatus.textContent = e.message || 'Failed.';
else alert(e.message || 'Failed.'); else alert(e.message || 'Failed.');
})
.finally(function () {
// State will be recalculated by renderModal/renderRun.
}); });
}); });
} }
@ -956,7 +976,8 @@ if (tStatus) tStatus.textContent = '';
currentRunId = run.id || null; currentRunId = run.id || null;
if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus(); if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus();
if (window.__rcmSetCreateDisabled) window.__rcmSetCreateDisabled(!currentRunId); if (window.__rcmRenderAutotaskInfo) window.__rcmRenderAutotaskInfo(run);
if (window.__rcmSetCreateDisabled) window.__rcmSetCreateDisabled(!currentRunId || !!run.autotask_ticket_id);
if (btnMarkSuccessOverride) { if (btnMarkSuccessOverride) {
var _rs = (run.status || '').toString().toLowerCase(); var _rs = (run.status || '').toString().toLowerCase();
var _canOverride = !!currentRunId && !run.missed && (_rs.indexOf('override') === -1) && (_rs.indexOf('success') === -1); var _canOverride = !!currentRunId && !run.missed && (_rs.indexOf('override') === -1) && (_rs.indexOf('success') === -1);
@ -1144,9 +1165,10 @@ if (tStatus) tStatus.textContent = '';
var dot = run.missed ? "dot-missed" : statusDotClass(run.status); var dot = run.missed ? "dot-missed" : statusDotClass(run.status);
var dotHtml = dot ? ('<span class="status-dot ' + dot + ' me-2" aria-hidden="true"></span>') : ''; var dotHtml = dot ? ('<span class="status-dot ' + dot + ' me-2" aria-hidden="true"></span>') : '';
var reviewedMark = run.is_reviewed ? ' <span class="ms-2" title="Reviewed" aria-label="Reviewed"></span>' : ''; var reviewedMark = run.is_reviewed ? ' <span class="ms-2" title="Reviewed" aria-label="Reviewed"></span>' : '';
var ticketMark = run.autotask_ticket_id ? ' <span class="ms-2" title="Autotask ticket created" aria-label="Autotask ticket">🎫</span>' : '';
a.title = run.status || ''; a.title = run.status || '';
a.innerHTML = dotHtml + '<span class="text-nowrap">' + escapeHtml(run.run_at || 'Run') + '</span>' + reviewedMark; a.innerHTML = dotHtml + '<span class="text-nowrap">' + escapeHtml(run.run_at || 'Run') + '</span>' + reviewedMark + ticketMark;
a.addEventListener('click', function (ev) { a.addEventListener('click', function (ev) {
ev.preventDefault(); ev.preventDefault();
renderRun(data, idx); renderRun(data, idx);

View File

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

View File

@ -0,0 +1,464 @@
# Backupchecks Autotask Integration
## Functional Design Phase 1
_Last updated: 2026-01-13_
---
## 1. Scope & Goals
This document describes the **functional design and agreed decisions** for the first phase of the Autotask integration in Backupchecks.
Goals for phase 1:
- Allow operators to **manually create Autotask tickets** from Backupchecks.
- Ensure **full operator control** over when a ticket is created.
- Prevent ticket spam and duplicate tickets.
- Maintain clear ownership between Backupchecks and Autotask.
- Provide a safe and auditable way to resolve tickets from Backupchecks.
Out of scope for phase 1:
- Automatic ticket creation
- Automatic ticket closing on success
- Issue correlation across multiple runs
- Time entry creation or modification
---
## 2. Core Principles (Leading)
These principles apply to all design and implementation choices:
- Autotask is an **external authoritative system** (PSA).
- Backupchecks is a **consumer**, not an owner, of PSA data.
- **IDs are leading**, names are display-only.
- All PSA mappings are **explicit**, never implicit or automatic.
- Operators always retain **manual control**.
- Renaming in Autotask must **never break mappings**.
---
## 3. Customer ↔ Autotask Company Mapping
### 3.1 Mapping model
- Mapping is configured in the **Customers** screen.
- Mapping is a **1-to-1 explicit relationship**.
- Stored values per customer:
- PSA type: `autotask`
- Autotask Company ID (leading)
- Autotask Company Name (cached for display)
- Last sync timestamp
- Mapping status: `ok | renamed | missing | invalid`
> **Note:** The Autotask Company ID is the source of truth. The name exists only for UI clarity.
### 3.2 Name synchronisation
- If the company name is changed in Autotask:
- Backupchecks updates the cached name automatically.
- The mapping remains intact.
- Backupchecks customer names are **independent** and never overwritten.
### 3.3 Failure scenarios
- Autotask company deleted or inaccessible:
- Mapping status becomes `invalid`.
- Ticket creation is blocked.
- UI clearly indicates broken mapping.
---
## 4. Ticket Creation Model
### 4.1 Operator-driven creation
- Tickets are created **only** via an explicit operator action.
- Location: **Run Checks** page.
- Manual ticket number input is removed.
- A new action replaces it:
- **“Create Autotask ticket”**
> **Rationale:** There are too many backup alerts that do not require a ticket. Human judgement remains essential.
### 4.2 One ticket per run (Key decision)
- **Exactly one ticket per Run**.
- A run can never create multiple tickets.
- If a ticket exists:
- Creation action is replaced by:
- “Open ticket”
- (Later) “Add note”
> **Rationale:** Multiple errors within a run often share the same root cause. This prevents ticket flooding.
### 4.3 Ticket contents (baseline)
Minimum ticket fields:
- Subject:
- `[Backupchecks] <Customer> - <Job> - <Status>`
- Description:
- Run date/time
- Backup type and job
- Affected objects (e.g. HV01, USB Disk)
- Error / warning messages
- Reference to Backupchecks (URL or identifier)
Optional (configurable later):
- Queue
- Issue type / category
- Priority mapping
---
## 5. Ticket State Tracking in Backupchecks
Per Run, Backupchecks stores:
- Autotask Ticket ID
- Autotask Ticket Number
- Ticket URL (optional)
- Created by (operator)
- Created at timestamp
- Last known ticket status (snapshot)
This ensures:
- No duplicate tickets
- Full audit trail
n- Clear operator feedback
---
## 5A. Ticket Content Composition Rules
This chapter defines how Backupchecks determines **what content is placed in an Autotask ticket**, with the explicit goal of keeping tickets readable and actionable.
### 5A.1 Guiding principle
- A ticket is a **signal**, not a log file.
- The ticket must remain readable for the ticket owner.
- Full technical details always remain available in Backupchecks.
### 5A.2 Content hierarchy (deterministic)
Backupchecks applies the following strict hierarchy when composing ticket content:
1. **Overall remark** (run-level summary) if present, this is leading.
2. **Object-level messages** used only when no overall remark exists.
This hierarchy is fixed and non-configurable in phase 1.
### 5A.3 Scenario A Overall remark present
If an overall remark exists for the run:
- The ticket description contains:
- The overall remark
- Job name, run date/time, and status
- Object-level errors are **not listed in full**.
- A short informational line is added:
- “Multiple objects reported errors. See Backupchecks for full details.”
> **Rationale:** The overall remark already represents a consolidated summary. Listing many objects would reduce ticket clarity.
### 5A.4 Scenario B No overall remark
If no overall remark exists:
- The ticket description includes object-level errors.
- Object listings are **explicitly limited**:
- A maximum of *N* objects (exact value defined during implementation)
- If more objects are present:
- “And X additional objects reported similar errors.”
> **Rationale:** Prevents large, unreadable tickets while still providing concrete examples.
### 5A.5 Mandatory reference to Backupchecks
Every ticket created by Backupchecks must include a **direct link to the Job Details page of the originating run**.
This link is intended as the primary navigation entry point for the ticket owner.
The ticket description must include:
- Job name
- Run date/time
- A clickable URL pointing to the Job Details page of that run in Backupchecks
> **Rationale:** The Job Details page provides the most complete and structured context for investigation.
This ensures:
- Full traceability
- Fast access to complete technical details
---
### 5A.6 Explicit exclusions
The following content is deliberately excluded from ticket descriptions:
- Complete object lists when large
- Repeated identical error messages
- Raw technical dumps or stack traces
---
## 6. Ticket Resolution from Backupchecks
### 6.1 Resolution policy
Backupchecks **may resolve** an Autotask ticket **only if**:
- The ticket exists
- The ticket is not already closed
- **No time entries are present on the ticket**
This rule is **mandatory and non-configurable**.
> **Rationale:** Prevents financial and operational conflicts inside Autotask.
### 6.2 Behaviour when time entries exist
If an operator clicks **Resolve ticket** but the ticket **contains time entries**:
- The ticket **must not be closed** by Backupchecks.
- Backupchecks **adds an internal system note** to the ticket stating that it was marked as resolved from Backupchecks.
- The ticket remains open for the ticket owner to review and close manually.
Proposed internal system note text:
> `Ticket marked as resolved in Backupchecks, but not closed automatically because time entries are present.`
> **Rationale:** Ensures the ticket owner is explicitly informed without violating Autotask process or financial controls.
### 6.3 Closing note (fixed text)
When resolving a ticket **and no time entries are present**, Backupchecks always adds the following **internal system note** **before closing**:
> `Ticket resolved via Backupchecks after verification that the backup issue is no longer present.`
Characteristics:
- Fixed text (no operator editing in phase 1)
- **System / internal note** (never customer-facing)
- Ensures auditability and traceability
---
---
## 6A. Handling Existing Tickets & Compatibility Mode
### 6A.1 Existing manual ticket numbers
In the pre-integration workflow, a run may already contain a manually entered ticket number.
When Autotask integration is **enabled**:
- Existing ticket numbers remain visible.
- Backupchecks may offer a one-time action:
- **“Link existing Autotask ticket”**
- This validates the ticket in Autotask and stores the **Autotask Ticket ID**.
> **Note:** Without an Autotask Ticket ID, Backupchecks must not attempt to resolve a ticket.
When Autotask integration is **disabled**:
- The current/manual workflow applies (manual ticket number entry).
### 6A.2 Linking existing Autotask tickets
When integration is enabled, operators can link an existing Autotask ticket to a run:
- Search/select a ticket (preferably by ticket number)
- Store:
- Autotask Ticket ID
- Autotask Ticket Number
- Ticket URL (optional)
After linking:
- The run behaves like an integration-created ticket for viewing and resolution rules.
### 6A.3 Compatibility mode (optional setting)
Optional setting (recommended for transition periods):
- **“Allow manual ticket number entry when Autotask is enabled”** (default: OFF)
Behaviour:
- When ON, operators can still manually enter a ticket number even if integration is enabled.
- Resolve from Backupchecks is still only possible for tickets that have a validated Autotask Ticket ID.
> **Rationale:** Provides a safe escape hatch during rollout and migration.
---
## 6B. Deleted Tickets in Autotask
Tickets may be deleted in Autotask. When a ticket referenced by Backupchecks is deleted, the linkage becomes invalid.
### 6B.1 Detection
When Backupchecks attempts to fetch the ticket by Autotask Ticket ID:
- If Autotask returns “not found” (deleted/missing), Backupchecks marks the linkage as **broken**.
### 6B.2 Behaviour when a ticket is deleted
- The run keeps the historical reference (ticket number/ID) for audit purposes.
- The ticket state is shown as:
- **“Missing in Autotask (deleted)”**
- Actions are blocked:
- No “Open ticket” (if no valid URL)
- No “Resolve ticket”
- Operators can choose:
- **Re-link to another ticket** (if the ticket was recreated or replaced)
- **Create a new Autotask ticket** (creates a new link for that run)
> **Note:** Backupchecks should never silently remove the stored linkage, to preserve auditability.
### 6B.3 Optional: periodic validation
Optionally (later), Backupchecks may periodically validate linked ticket IDs and flag missing tickets.
---
## 7. Backupchecks Settings
### 7.1 New settings section
**Settings → Extensions & Integrations → Autotask**
### 7.2 Required settings
- Enable Autotask integration (on/off)
- Environment: Sandbox / Production
- API Username
- API Password
- Tracking Identifier
### 7.3 Ticket creation defaults (configurable)
These defaults are applied when Backupchecks creates a new Autotask ticket.
- Ticket Source (default): **Monitoring Alert**
- Default Queue (default): **Helpdesk**
- Default Status (default): **New**
- Priority mapping:
- Warning → **Medium**
- Error → **High**
> **Note:** Issue Type / Category is intentionally **not set** by Backupchecks and will be assigned by the ticket owner or traffic manager.
---
### 7.3A Backupchecks Base URL
- Base URL of the Backupchecks instance (e.g. `https://backupchecks.example.com`)
This value is required to construct:
- Direct links to Job Details pages
- Stable references inside Autotask tickets
> **Note:** This setting is mandatory for ticket creation and must be validated.
---
### 7.4 Dynamic reference data
Backupchecks must retrieve the following reference data from Autotask and present it in Settings:
- Available Queues
- Available Ticket Sources
These lists are:
- Loaded on demand (or via refresh action)
- Stored for selection in Settings
> **Rationale:** Prevents hard-coded values and keeps Backupchecks aligned with Autotask configuration changes.
### 7.5 Resolve configuration
- Allow resolving tickets from Backupchecks (on/off)
- Closing note texts (read-only, fixed):
- Standard resolve note
- Time-entry-blocked resolve note
### 7.6 Validation & diagnostics
- Test connection
- Validate configuration (credentials, reference data access)
- Optional API logging level
---
## 8. Roles & Permissions
- Admin / Operator:
- Create tickets
- Resolve tickets (if allowed)
- Reporter:
- View ticket number and link
- No create or resolve actions
---
## 9. Handling Existing, Linked and Deleted Tickets
### 9.1 Existing tickets (pre-integration)
- Runs that already contain a manually entered ticket number remain valid.
- When Autotask integration is enabled, operators may optionally:
- Link the run to an existing Autotask ticket (validated against Autotask).
- After linking, the run follows the same rules as integration-created tickets.
> **Note:** This optional compatibility flow exists to support a gradual transition and avoids forced migration.
### 9.2 Optional compatibility mode
- Optional setting: **Allow manual ticket number entry when Autotask is enabled**
- Default: OFF
- Intended as a temporary transition mechanism.
### 9.3 Deleted tickets in Autotask (important case)
Tickets may be deleted directly in Autotask. Backupchecks must handle this safely and explicitly.
Behaviour:
- Backupchecks never assumes tickets exist based on stored data alone.
- On any ticket-related action (view, resolve, open):
- Backupchecks validates the ticket ID against Autotask.
If Autotask returns *not found*:
- The ticket is marked as **Deleted (external)**.
- The existing link is preserved as historical data but marked inactive.
- No further actions (resolve, update) are allowed on that ticket.
UI behaviour:
- Ticket number remains visible with a clear indicator:
- “Ticket deleted in Autotask”
- Operator is offered one explicit action:
- “Create new Autotask ticket” (results in a new ticket linked to the same run)
> **Rationale:** Ticket deletion is an external administrative decision. Backupchecks records the fact but does not attempt to repair or hide it.
### 9.4 Why links are not silently removed
- Silent removal would break audit trails.
- Historical runs must retain context, even if external objects no longer exist.
- Operators must explicitly decide how to proceed.
---
## 10. Explicit Non-Goals (Phase 1)
The following are explicitly excluded:
- Automatic ticket creation
- Automatic ticket closing
- Automatic re-creation of deleted tickets
- Updating ticket content after creation
- Multiple tickets per run
- Time entry handling
- Multi-PSA support
---
## 11. Phase 1 Summary
Phase 1 delivers:
- Safe, controlled PSA integration
- Operator-driven ticket lifecycle
- Explicit handling of legacy, linked and deleted tickets
- Clear audit trail
- Minimal risk to Autotask data integrity
This design intentionally prioritises **predictability and control** over automation.
Future phases may build on this foundation.

View File

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

View File

@ -1,3 +1,133 @@
## 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.
*** ***