Compare commits

...

47 Commits

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

View File

@ -1 +1 @@
v20260113-08-vspc-object-linking-normalize
v20260119-14-fix-routes-runchecks-syntax

View File

@ -0,0 +1,540 @@
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import requests
@dataclass
class AutotaskZoneInfo:
zone_name: str
api_url: str
web_url: Optional[str] = None
ci: Optional[int] = None
class AutotaskError(RuntimeError):
def __init__(self, message: str, status_code: Optional[int] = None) -> None:
super().__init__(message)
self.status_code = status_code
class AutotaskClient:
def __init__(
self,
username: str,
password: str,
api_integration_code: str,
environment: str = "production",
timeout_seconds: int = 30,
) -> None:
self.username = username
self.password = password
self.api_integration_code = api_integration_code
self.environment = (environment or "production").strip().lower()
self.timeout_seconds = timeout_seconds
self._zone_info: Optional[AutotaskZoneInfo] = None
self._zoneinfo_base_used: Optional[str] = None
def _zoneinfo_bases(self) -> List[str]:
"""Return a list of zoneInformation base URLs to try.
Autotask tenants can behave differently for Sandbox vs Production.
To keep connection testing reliable, we try the expected base first
and fall back to the alternative if needed.
"""
prod = "https://webservices.autotask.net/atservicesrest"
sb = "https://webservices2.autotask.net/atservicesrest"
if self.environment == "sandbox":
return [sb, prod]
return [prod, sb]
def get_zone_info(self) -> AutotaskZoneInfo:
if self._zone_info is not None:
return self._zone_info
last_error: Optional[str] = None
data: Optional[Dict[str, Any]] = None
for base in self._zoneinfo_bases():
url = f"{base.rstrip('/')}/v1.0/zoneInformation"
params = {"user": self.username}
try:
resp = requests.get(url, params=params, timeout=self.timeout_seconds)
except Exception as exc:
last_error = f"ZoneInformation request failed for {base}: {exc}"
continue
if resp.status_code >= 400:
last_error = f"ZoneInformation request failed for {base} (HTTP {resp.status_code})."
continue
try:
data = resp.json()
except Exception:
last_error = f"ZoneInformation response from {base} is not valid JSON."
continue
self._zoneinfo_base_used = base
break
if data is None:
raise AutotaskError(last_error or "ZoneInformation request failed.")
zone = AutotaskZoneInfo(
zone_name=str(data.get("zoneName") or ""),
api_url=str(data.get("url") or "").rstrip("/"),
web_url=(str(data.get("webUrl") or "").rstrip("/") or None),
ci=(int(data["ci"]) if str(data.get("ci") or "").isdigit() else None),
)
if not zone.api_url:
raise AutotaskError("ZoneInformation did not return an API URL.")
self._zone_info = zone
return zone
def _headers(self) -> Dict[str, str]:
# Autotask REST API requires the ApiIntegrationCode header.
# Some tenants/proxies appear picky despite headers being case-insensitive,
# so we include both common casings for maximum compatibility.
return {
"ApiIntegrationCode": self.api_integration_code,
"APIIntegrationcode": self.api_integration_code,
"Content-Type": "application/json",
"Accept": "application/json",
}
def _request(
self,
method: str,
path: str,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
) -> Any:
zone = self.get_zone_info()
base = zone.api_url.rstrip("/")
url = f"{base}/v1.0/{path.lstrip('/')}"
headers = self._headers()
def do_request(use_basic_auth: bool, extra_headers: Optional[Dict[str, str]] = None):
h = dict(headers)
if extra_headers:
h.update(extra_headers)
return requests.request(
method=method.upper(),
url=url,
headers=h,
params=params or None,
json=json_body if json_body is not None else None,
auth=(self.username, self.password) if use_basic_auth else None,
timeout=self.timeout_seconds,
)
try:
# Primary auth method: HTTP Basic (username + API secret)
resp = do_request(use_basic_auth=True)
# Compatibility fallback: some environments accept credentials only via headers.
if resp.status_code == 401:
resp = do_request(
use_basic_auth=False,
extra_headers={"UserName": self.username, "Secret": self.password},
)
except Exception as exc:
raise AutotaskError(f"Request failed: {exc}") from exc
if resp.status_code == 401:
zi_base = self._zoneinfo_base_used or "unknown"
raise AutotaskError(
"Authentication failed (HTTP 401). "
"Verify API Username, API Secret, and ApiIntegrationCode. "
f"Environment={self.environment}, ZoneInfoBase={zi_base}, ZoneApiUrl={zone.api_url}."
,
status_code=401,
)
if resp.status_code == 403:
raise AutotaskError(
"Access forbidden (HTTP 403). API user permissions may be insufficient.",
status_code=403,
)
if resp.status_code == 404:
raise AutotaskError(f"Resource not found (HTTP 404) for path: {path}", status_code=404)
if resp.status_code >= 400:
raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).", status_code=resp.status_code)
try:
return resp.json()
except Exception as exc:
raise AutotaskError("Autotask API response is not valid JSON.") from exc
def _as_items_list(self, payload: Any) -> List[Dict[str, Any]]:
"""Normalize common Autotask REST payload shapes to a list of dicts."""
if payload is None:
return []
if isinstance(payload, list):
return [x for x in payload if isinstance(x, dict)]
if isinstance(payload, dict):
items = payload.get("items")
if isinstance(items, list):
return [x for x in items if isinstance(x, dict)]
fields = payload.get("fields")
if isinstance(fields, list):
return [x for x in fields if isinstance(x, dict)]
# Some endpoints may return a single object.
if "id" in payload:
return [payload]
return []
def _get_collection(self, resource_name: str) -> List[Dict[str, Any]]:
"""Fetch a reference collection via GET /<resource>.
Note: Not all Autotask entities support /query. Reference data like Queues and
TicketSources is typically retrieved via a simple collection GET.
"""
data = self._request("GET", resource_name)
return self._as_items_list(data)
def _get_entity_fields(self, entity_name: str) -> List[Dict[str, Any]]:
data = self._request("GET", f"{entity_name}/entityInformation/fields")
return self._as_items_list(data)
def _call_picklist_values(self, picklist_values_path: str) -> List[Dict[str, Any]]:
# picklistValues path can be returned as a full URL or as a relative path.
path = (picklist_values_path or "").strip()
if not path:
return []
# If a full URL is returned, strip everything up to /v1.0/
if "/v1.0/" in path:
path = path.split("/v1.0/", 1)[1]
# If it includes the base API URL without /v1.0, strip to resource path.
if "/atservicesrest/" in path and "/v1.0/" not in picklist_values_path:
# Fallback: attempt to strip after atservicesrest/
path = path.split("/atservicesrest/", 1)[1]
if path.startswith("v1.0/"):
path = path.split("v1.0/", 1)[1]
data = self._request("GET", path)
return self._as_items_list(data)
def get_queues(self) -> List[Dict[str, Any]]:
"""Return Ticket Queue picklist values.
Autotask does not expose a universal top-level Queues entity in all tenants.
The reliable source is the Tickets.queueID picklist metadata.
"""
return self._get_ticket_picklist_values(field_names=["queueid", "queue"])
def get_ticket_sources(self) -> List[Dict[str, Any]]:
"""Return Ticket Source picklist values.
Similar to queues, Ticket Source values are best retrieved via the
Tickets.source picklist metadata to avoid relying on optional entities.
"""
return self._get_ticket_picklist_values(field_names=["source", "sourceid"])
def search_companies(self, query: str, limit: int = 25) -> List[Dict[str, Any]]:
"""Search Companies by company name.
Uses the standard REST query endpoint:
GET /Companies/query?search={...}
Returns a minimal list of dicts with keys: id, companyName, isActive.
"""
q = (query or "").strip()
if not q:
return []
# Keep payload small and predictable.
# Field names in filters are case-insensitive in many tenants, but the docs
# commonly show CompanyName.
search_payload: Dict[str, Any] = {
"filter": [
{"op": "contains", "field": "CompanyName", "value": q},
],
"maxRecords": int(limit) if int(limit) > 0 else 25,
}
params = {"search": json.dumps(search_payload)}
data = self._request("GET", "Companies/query", params=params)
items = self._as_items_list(data)
out: List[Dict[str, Any]] = []
for it in items:
if not isinstance(it, dict):
continue
cid = it.get("id")
name = it.get("companyName") or it.get("CompanyName") or ""
try:
cid_int = int(cid)
except Exception:
continue
out.append(
{
"id": cid_int,
"companyName": str(name),
"isActive": bool(it.get("isActive", True)),
}
)
out.sort(key=lambda x: (x.get("companyName") or "").lower())
return out
def get_company(self, company_id: int) -> Dict[str, Any]:
"""Fetch a single Company by ID."""
return self._request("GET", f"Companies/{int(company_id)}")
def _get_ticket_picklist_values(self, field_names: List[str]) -> List[Dict[str, Any]]:
"""Retrieve picklist values for a Tickets field.
Autotask field metadata can vary between tenants/environments.
We first try exact name matches, then fall back to a contains-match
on the metadata field name/label for picklist fields.
"""
fields = self._get_entity_fields("Tickets")
wanted = {n.strip().lower() for n in (field_names or []) if str(n).strip()}
def _field_label(f: Dict[str, Any]) -> str:
# Autotask metadata commonly provides either "label" or "displayName".
return str(f.get("label") or f.get("displayName") or "").strip().lower()
field: Optional[Dict[str, Any]] = None
# 1) Exact name match
for f in fields:
name = str(f.get("name") or "").strip().lower()
if name in wanted:
field = f
break
# 2) Fallback: contains match for picklists (handles QueueID vs TicketQueueID etc.)
if field is None and wanted:
candidates: List[Dict[str, Any]] = []
for f in fields:
if not bool(f.get("isPickList")):
continue
name = str(f.get("name") or "").strip().lower()
label = _field_label(f)
if any(w in name for w in wanted) or any(w in label for w in wanted):
candidates.append(f)
if candidates:
# Prefer the most specific/shortest name match to avoid overly broad matches.
candidates.sort(key=lambda x: len(str(x.get("name") or "")))
field = candidates[0]
if not field:
raise AutotaskError(
"Unable to locate Tickets field metadata for picklist retrieval: "
f"{sorted(wanted)}"
)
if not bool(field.get("isPickList")):
raise AutotaskError(f"Tickets.{field.get('name')} is not marked as a picklist in Autotask metadata.")
picklist_values = field.get("picklistValues")
# Autotask may return picklist values inline (as a list) or as a URL/path.
if isinstance(picklist_values, list):
return [x for x in picklist_values if isinstance(x, dict)]
if not isinstance(picklist_values, str) or not picklist_values.strip():
raise AutotaskError(f"Tickets.{field.get('name')} metadata did not include picklist values.")
return self._call_picklist_values(picklist_values)
def get_ticket_priorities(self) -> List[Dict[str, Any]]:
"""Return Ticket Priority picklist values.
We intentionally retrieve this from entity metadata to prevent hardcoded priority IDs.
"""
fields = self._get_entity_fields("Tickets")
priority_field: Optional[Dict[str, Any]] = None
def _field_label(f: Dict[str, Any]) -> str:
return str(f.get("label") or f.get("displayName") or "").strip().lower()
# Exact match first
for f in fields:
name = str(f.get("name") or "").strip().lower()
if name == "priority":
priority_field = f
break
# Fallback: contains match (handles variations like TicketPriority)
if priority_field is None:
candidates: List[Dict[str, Any]] = []
for f in fields:
if not bool(f.get("isPickList")):
continue
name = str(f.get("name") or "").strip().lower()
label = _field_label(f)
if "priority" in name or "priority" in label:
candidates.append(f)
if candidates:
candidates.sort(key=lambda x: len(str(x.get("name") or "")))
priority_field = candidates[0]
if not priority_field:
raise AutotaskError("Unable to locate a Tickets priority picklist field in Autotask metadata.")
if not bool(priority_field.get("isPickList")):
raise AutotaskError("Tickets.priority is not marked as a picklist in Autotask metadata.")
picklist_values = priority_field.get("picklistValues")
if isinstance(picklist_values, list):
return [x for x in picklist_values if isinstance(x, dict)]
if not isinstance(picklist_values, str) or not picklist_values.strip():
raise AutotaskError("Tickets.priority metadata did not include picklist values.")
return self._call_picklist_values(picklist_values)
def get_ticket_statuses(self) -> List[Dict[str, Any]]:
"""Return Ticket Status picklist values.
We retrieve this from Tickets field metadata to avoid hardcoded status IDs.
"""
return self._get_ticket_picklist_values(field_names=["status", "statusid"])
def create_ticket(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Create a Ticket in Autotask.
Uses POST /Tickets.
Returns the created ticket object (as returned by Autotask).
"""
if not isinstance(payload, dict) or not payload:
raise AutotaskError("Ticket payload is empty.")
data = self._request("POST", "Tickets", json_body=payload)
# Autotask commonly returns only an itemId on create.
# We normalize that into a dict with an "id" key so callers can
# perform a follow-up GET /Tickets/{id} to retrieve the full object
# and the human-facing ticketNumber.
if isinstance(data, dict):
if "item" in data and isinstance(data.get("item"), dict):
return data["item"]
if "items" in data and isinstance(data.get("items"), list) and data.get("items"):
first = data.get("items")[0]
if isinstance(first, dict):
return first
# Autotask create responses often look like: {"itemId": 12345}
item_id = data.get("itemId")
if item_id is None:
item_id = data.get("itemID")
if item_id is not None:
try:
tid = int(item_id)
except Exception:
tid = 0
if tid > 0:
return {"id": tid}
if "id" in data:
return data
# Fallback: return normalized first item if possible
items = self._as_items_list(data)
if items:
return items[0]
raise AutotaskError("Autotask did not return a created ticket object.")
def get_ticket(self, ticket_id: int) -> Dict[str, Any]:
"""Retrieve a Ticket by Autotask Ticket ID.
Uses GET /Tickets/{id}.
This is the authoritative retrieval method and is mandatory after creation,
because the create response does not reliably include the human-facing
ticket number.
"""
try:
tid = int(ticket_id)
except Exception:
raise AutotaskError("Invalid ticket id.")
if tid <= 0:
raise AutotaskError("Invalid ticket id.")
data = self._request("GET", f"Tickets/{tid}")
if isinstance(data, dict):
if "item" in data and isinstance(data.get("item"), dict):
return data["item"]
if "items" in data and isinstance(data.get("items"), list) and data.get("items"):
first = data.get("items")[0]
if isinstance(first, dict):
return first
# Some environments return the raw object
if "id" in data or "ticketNumber" in data or "number" in data:
return data
items = self._as_items_list(data)
if items:
return items[0]
raise AutotaskError("Autotask did not return a ticket object.")
def query_tickets_by_ids(
self,
ticket_ids: List[int],
*,
exclude_status_ids: Optional[List[int]] = None,
) -> List[Dict[str, Any]]:
"""Query Tickets by ID, optionally excluding statuses.
Uses POST /Tickets/query.
Note:
- This endpoint is not authoritative (tickets can be missing).
- Call get_ticket(id) as a fallback for missing IDs.
"""
ids: List[int] = []
for x in ticket_ids or []:
try:
v = int(x)
except Exception:
continue
if v > 0:
ids.append(v)
if not ids:
return []
flt: List[Dict[str, Any]] = [
{
"op": "in",
"field": "id",
"value": ids,
}
]
ex: List[int] = []
for x in exclude_status_ids or []:
try:
v = int(x)
except Exception:
continue
if v > 0:
ex.append(v)
if ex:
flt.append(
{
"op": "notIn",
"field": "status",
"value": ex,
}
)
data = self._request("POST", "Tickets/query", json_body={"filter": flt})
return self._as_items_list(data)

View File

@ -347,6 +347,8 @@ def api_ticket_resolve(ticket_id: int):
open_scope = TicketScope.query.filter_by(ticket_id=ticket.id, resolved_at=None).first()
if open_scope is None and ticket.resolved_at is None:
ticket.resolved_at = now
if getattr(ticket, "resolved_origin", None) is None:
ticket.resolved_origin = "backupchecks"
db.session.commit()
except Exception as exc:
@ -358,6 +360,8 @@ def api_ticket_resolve(ticket_id: int):
# Global resolve (from central ticket list): resolve ticket and all scopes
if ticket.resolved_at is None:
ticket.resolved_at = now
if getattr(ticket, "resolved_origin", None) is None:
ticket.resolved_origin = "backupchecks"
try:
# Resolve any still-open scopes

View File

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

View File

@ -4,7 +4,8 @@ import calendar
from datetime import date, datetime, time, timedelta, timezone
from flask import jsonify, render_template, request
from flask import jsonify, render_template, request, url_for
from urllib.parse import urljoin
from flask_login import current_user, login_required
from sqlalchemy import and_, or_, func, text
@ -32,9 +33,369 @@ from ..models import (
MailMessage,
MailObject,
Override,
Ticket,
TicketJobRun,
TicketScope,
User,
)
AUTOTASK_TERMINAL_STATUS_IDS = {5}
def _ensure_internal_ticket_for_autotask(
*,
ticket_number: str,
job: Job | None,
run_ids: list[int],
now: datetime,
) -> Ticket | None:
"""Best-effort: ensure an internal Ticket exists and is linked to the provided runs."""
code = (ticket_number or "").strip().upper()
if not code:
return None
ticket = Ticket.query.filter(Ticket.ticket_code == code).first()
if ticket is None:
# Align with manual ticket creation: active_from_date is today (Amsterdam date).
active_from = _to_amsterdam_date(now) or now.date()
ticket = Ticket(
ticket_code=code,
description="",
active_from_date=active_from,
start_date=now,
)
db.session.add(ticket)
db.session.flush()
# Ensure job scope exists (for Daily Jobs / Job Details filtering), best-effort.
if job is not None and getattr(job, "id", None):
try:
existing = TicketScope.query.filter_by(ticket_id=ticket.id, scope_type="job", job_id=job.id).first()
if existing is None:
db.session.add(
TicketScope(
ticket_id=ticket.id,
scope_type="job",
customer_id=job.customer_id,
backup_software=job.backup_software,
backup_type=job.backup_type,
job_id=job.id,
job_name_match=job.job_name,
job_name_match_mode="exact",
)
)
except Exception:
pass
# Ensure run links.
for rid in run_ids or []:
if rid <= 0:
continue
if not TicketJobRun.query.filter_by(ticket_id=ticket.id, job_run_id=rid).first():
db.session.add(TicketJobRun(ticket_id=ticket.id, job_run_id=rid, link_source="autotask"))
return ticket
def _resolve_internal_ticket_for_job(
*,
ticket: Ticket,
job: Job | None,
run_ids: list[int],
now: datetime,
) -> None:
"""Resolve the ticket (and its job scope) as PSA-driven, best-effort."""
if ticket.resolved_at is None:
ticket.resolved_at = now
if getattr(ticket, "resolved_origin", None) is None:
ticket.resolved_origin = "psa"
# Resolve all still-open scopes.
try:
TicketScope.query.filter_by(ticket_id=ticket.id, resolved_at=None).update({"resolved_at": now})
except Exception:
pass
# Ensure job scope exists and is resolved.
if job is not None and getattr(job, "id", None):
try:
scope = TicketScope.query.filter_by(ticket_id=ticket.id, scope_type="job", job_id=job.id).first()
if scope is None:
scope = TicketScope(
ticket_id=ticket.id,
scope_type="job",
customer_id=job.customer_id,
backup_software=job.backup_software,
backup_type=job.backup_type,
job_id=job.id,
job_name_match=job.job_name,
job_name_match_mode="exact",
resolved_at=now,
)
db.session.add(scope)
else:
if scope.resolved_at is None:
scope.resolved_at = now
except Exception:
pass
# Keep audit links to runs.
for rid in run_ids or []:
if rid <= 0:
continue
if not TicketJobRun.query.filter_by(ticket_id=ticket.id, job_run_id=rid).first():
db.session.add(TicketJobRun(ticket_id=ticket.id, job_run_id=rid, link_source="autotask"))
def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None:
"""Phase 2: Read-only PSA-driven ticket completion sync.
Best-effort: never blocks page load.
"""
if not run_ids:
return
settings = _get_or_create_settings()
if not bool(getattr(settings, "autotask_enabled", False)):
return
# Build ticket id -> run ids mapping.
runs = JobRun.query.filter(JobRun.id.in_(run_ids)).all()
ticket_to_runs: dict[int, list[JobRun]] = {}
for r in runs:
tid = getattr(r, "autotask_ticket_id", None)
try:
tid_int = int(tid) if tid is not None else 0
except Exception:
tid_int = 0
if tid_int <= 0:
continue
ticket_to_runs.setdefault(tid_int, []).append(r)
if not ticket_to_runs:
return
try:
client = _build_autotask_client_from_settings()
except Exception:
return
now = datetime.utcnow()
ticket_ids = sorted(ticket_to_runs.keys())
# Optimization: query non-terminal tickets first; fallback to GET by id for missing.
try:
active_items = client.query_tickets_by_ids(ticket_ids, exclude_status_ids=sorted(AUTOTASK_TERMINAL_STATUS_IDS))
except Exception:
active_items = []
active_map: dict[int, dict] = {}
for it in active_items or []:
try:
iid = int(it.get("id") or 0)
except Exception:
iid = 0
if iid > 0:
active_map[iid] = it
missing_ids = [tid for tid in ticket_ids if tid not in active_map]
# Process active tickets: backfill ticket numbers + ensure internal ticket link.
try:
for tid, item in active_map.items():
runs_for_ticket = ticket_to_runs.get(tid) or []
ticket_number = None
if isinstance(item, dict):
ticket_number = item.get("ticketNumber") or item.get("number") or item.get("ticket_number")
# Backfill missing stored ticket number.
if ticket_number:
for rr in runs_for_ticket:
if not (getattr(rr, "autotask_ticket_number", None) or "").strip():
rr.autotask_ticket_number = str(ticket_number).strip()
db.session.add(rr)
# Ensure internal ticket exists and is linked.
tn = (str(ticket_number).strip() if ticket_number else "")
if not tn:
# Try from DB
for rr in runs_for_ticket:
if (getattr(rr, "autotask_ticket_number", None) or "").strip():
tn = rr.autotask_ticket_number.strip()
break
job = Job.query.get(runs_for_ticket[0].job_id) if runs_for_ticket else None
_ensure_internal_ticket_for_autotask(
ticket_number=tn,
job=job,
run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)],
now=now,
)
except Exception:
# Continue to missing-id fallback.
pass
# Fallback for missing ids (could be terminal, deleted, or query omission).
for tid in missing_ids:
try:
t = client.get_ticket(tid)
except Exception:
continue
status_id = None
if isinstance(t, dict):
status_id = t.get("status") or t.get("statusId") or t.get("statusID")
try:
status_int = int(status_id) if status_id is not None else 0
except Exception:
status_int = 0
ticket_number = None
if isinstance(t, dict):
ticket_number = t.get("ticketNumber") or t.get("number") or t.get("ticket_number")
runs_for_ticket = ticket_to_runs.get(tid) or []
# Backfill stored ticket number if missing.
if ticket_number:
for rr in runs_for_ticket:
if not (getattr(rr, "autotask_ticket_number", None) or "").strip():
rr.autotask_ticket_number = str(ticket_number).strip()
db.session.add(rr)
job = Job.query.get(runs_for_ticket[0].job_id) if runs_for_ticket else None
tn = (str(ticket_number).strip() if ticket_number else "")
if not tn:
for rr in runs_for_ticket:
if (getattr(rr, "autotask_ticket_number", None) or "").strip():
tn = rr.autotask_ticket_number.strip()
break
internal_ticket = _ensure_internal_ticket_for_autotask(
ticket_number=tn,
job=job,
run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)],
now=now,
)
# If terminal in PSA: resolve internally.
if internal_ticket is not None and status_int in AUTOTASK_TERMINAL_STATUS_IDS:
_resolve_internal_ticket_for_job(
ticket=internal_ticket,
job=job,
run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)],
now=now,
)
try:
db.session.commit()
except Exception:
db.session.rollback()
def _build_autotask_client_from_settings():
"""Build an AutotaskClient from stored settings or raise a user-safe exception."""
settings = _get_or_create_settings()
if not getattr(settings, "autotask_enabled", False):
raise RuntimeError("Autotask integration is disabled.")
required = [
getattr(settings, "autotask_environment", None),
getattr(settings, "autotask_api_username", None),
getattr(settings, "autotask_api_password", None),
getattr(settings, "autotask_tracking_identifier", None),
]
if any(not (x and str(x).strip()) for x in required):
raise RuntimeError("Autotask settings incomplete.")
from ..integrations.autotask.client import AutotaskClient
return AutotaskClient(
username=settings.autotask_api_username,
password=settings.autotask_api_password,
api_integration_code=settings.autotask_tracking_identifier,
environment=settings.autotask_environment,
)
def _determine_autotask_severity(status_text: str | None) -> str:
s = (status_text or "").strip().lower()
if "warning" in s:
return "warning"
if "error" in s or "fail" in s:
return "error"
if "missed" in s:
return "error"
return "warning"
def _compose_autotask_ticket_description(
*,
settings,
job: Job,
run: JobRun,
status_display: str,
overall_message: str,
objects_payload: list[dict[str, str]],
) -> str:
tz_name = _get_ui_timezone_name() or "Europe/Amsterdam"
run_dt = run.run_at
run_at_str = _format_datetime(run_dt) if run_dt else "-"
base_url = (getattr(settings, "autotask_base_url", None) or "").strip()
job_rel = url_for("main.job_detail", job_id=job.id)
# Link to Job Details with a hint for the specific run.
job_link = urljoin(base_url.rstrip("/") + "/", job_rel.lstrip("/"))
if run.id:
job_link = f"{job_link}?run_id={int(run.id)}"
lines: list[str] = []
lines.append(f"Customer: {job.customer.name if job.customer else ''}")
lines.append(f"Job: {job.job_name or ''}")
lines.append(f"Backup: {job.backup_software or ''} / {job.backup_type or ''}")
lines.append(f"Run at ({tz_name}): {run_at_str}")
lines.append(f"Status: {status_display or ''}")
lines.append("")
overall_message = (overall_message or "").strip()
if overall_message:
lines.append("Summary:")
lines.append(overall_message)
lines.append("")
lines.append("Multiple objects reported messages. See Backupchecks for full details.")
else:
# Fallback to object-level messages with a hard limit.
limit = 10
shown = 0
total = 0
for o in objects_payload or []:
name = (o.get("name") or "").strip()
err = (o.get("error_message") or "").strip()
st = (o.get("status") or "").strip()
if not name:
continue
if not err and not st:
continue
total += 1
if shown >= limit:
continue
msg = err or st
lines.append(f"- {name}: {msg}")
shown += 1
if total == 0:
lines.append("No detailed object messages available. See Backupchecks for full details.")
elif total > shown:
lines.append(f"And {int(total - shown)} additional objects reported similar messages.")
lines.append("")
lines.append(f"Backupchecks details: {job_link}")
return "\n".join(lines).strip() + "\n"
# Grace window for matching real runs to an expected schedule slot.
# A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot.
MISSED_GRACE_WINDOW = timedelta(hours=1)
@ -338,6 +699,15 @@ def run_checks_page():
# Don't block the page if missed-run generation fails.
pass
# Phase 2 (read-only PSA driven): sync internal ticket resolved state based on PSA ticket status.
# Best-effort: never blocks page load.
try:
run_q = JobRun.query.filter(JobRun.reviewed_at.is_(None), JobRun.autotask_ticket_id.isnot(None))
run_ids = [int(x) for (x,) in run_q.with_entities(JobRun.id).limit(800).all()]
_poll_autotask_ticket_states_for_runs(run_ids=run_ids)
except Exception:
pass
# Aggregated per-job rows
base = (
db.session.query(
@ -595,11 +965,15 @@ def run_checks_page():
}
)
settings = _get_or_create_settings()
autotask_enabled = bool(getattr(settings, "autotask_enabled", False))
return render_template(
"main/run_checks.html",
rows=payload,
is_admin=(get_active_role() == "admin"),
include_reviewed=include_reviewed,
autotask_enabled=autotask_enabled,
)
@ -627,6 +1001,20 @@ def run_checks_details():
runs = q.order_by(func.coalesce(JobRun.run_at, JobRun.created_at).desc(), JobRun.id.desc()).limit(400).all()
# Prefetch internal ticket resolution info for Autotask-linked runs (Phase 2 UI).
autotask_codes = set()
for _r in runs:
code = (getattr(_r, "autotask_ticket_number", None) or "").strip()
if code:
autotask_codes.add(code)
ticket_by_code = {}
if autotask_codes:
try:
for _t in Ticket.query.filter(Ticket.ticket_code.in_(list(autotask_codes))).all():
ticket_by_code[_t.ticket_code] = _t
except Exception:
ticket_by_code = {}
runs_payload = []
for run in runs:
msg = MailMessage.query.get(run.mail_message_id) if run.mail_message_id else None
@ -732,6 +1120,20 @@ def run_checks_details():
except Exception:
pass
# Autotask ticket resolution info (derived from internal Ticket)
at_resolved = False
at_resolved_origin = ""
at_resolved_at = ""
try:
_code = (getattr(run, "autotask_ticket_number", None) or "").strip()
if _code and _code in ticket_by_code:
_t = ticket_by_code[_code]
at_resolved = getattr(_t, "resolved_at", None) is not None
at_resolved_origin = (getattr(_t, "resolved_origin", None) or "")
at_resolved_at = _format_datetime(getattr(_t, "resolved_at", None)) if getattr(_t, "resolved_at", None) else ""
except Exception:
pass
status_display = run.status or "-"
try:
status_display, _, _, _ov_id, _ov_reason = _apply_overrides_to_run(job, run)
@ -753,6 +1155,11 @@ def run_checks_details():
"mail": mail_meta,
"body_html": body_html,
"objects": objects_payload,
"autotask_ticket_id": getattr(run, "autotask_ticket_id", None),
"autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "",
"autotask_ticket_is_resolved": bool(at_resolved),
"autotask_ticket_resolved_origin": at_resolved_origin,
"autotask_ticket_resolved_at": at_resolved_at,
}
)
@ -770,6 +1177,186 @@ def run_checks_details():
return jsonify({"status": "ok", "job": job_payload, "runs": runs_payload})
@main_bp.post("/api/run-checks/autotask-ticket")
@login_required
@roles_required("admin", "operator")
def api_run_checks_create_autotask_ticket():
"""Create an Autotask ticket for a specific run.
Enforces: exactly one ticket per run.
"""
data = request.get_json(silent=True) or {}
try:
run_id = int(data.get("run_id") or 0)
except Exception:
run_id = 0
if run_id <= 0:
return jsonify({"status": "error", "message": "Invalid parameters."}), 400
run = JobRun.query.get(run_id)
if not run:
return jsonify({"status": "error", "message": "Run not found."}), 404
# If a ticket is already linked we normally prevent duplicate creation.
# Exception: if the linked ticket is resolved (e.g. resolved by PSA), allow creating a new ticket.
if getattr(run, "autotask_ticket_id", None):
already_resolved = False
try:
code = (getattr(run, "autotask_ticket_number", None) or "").strip()
if code:
t = Ticket.query.filter_by(ticket_code=code).first()
already_resolved = bool(getattr(t, "resolved_at", None)) if t else False
except Exception:
already_resolved = False
if not already_resolved:
return jsonify(
{
"status": "ok",
"ticket_id": int(run.autotask_ticket_id),
"ticket_number": getattr(run, "autotask_ticket_number", None) or "",
"already_exists": True,
}
)
# resolved -> continue, create a new Autotask ticket and overwrite current linkage.
job = Job.query.get(run.job_id)
if not job:
return jsonify({"status": "error", "message": "Job not found."}), 404
customer = Customer.query.get(job.customer_id) if getattr(job, "customer_id", None) else None
if not customer:
return jsonify({"status": "error", "message": "Customer not found."}), 404
if not getattr(customer, "autotask_company_id", None):
return jsonify({"status": "error", "message": "Customer has no Autotask company mapping."}), 400
if (getattr(customer, "autotask_mapping_status", None) or "").strip().lower() not in ("ok", "renamed"):
return jsonify({"status": "error", "message": "Autotask company mapping is not valid."}), 400
settings = _get_or_create_settings()
base_url = (getattr(settings, "autotask_base_url", None) or "").strip()
if not base_url:
return jsonify({"status": "error", "message": "Autotask Base URL is not configured."}), 400
# Required ticket defaults
if not getattr(settings, "autotask_default_queue_id", None):
return jsonify({"status": "error", "message": "Autotask default queue is not configured."}), 400
if not getattr(settings, "autotask_default_ticket_source_id", None):
return jsonify({"status": "error", "message": "Autotask default ticket source is not configured."}), 400
if not getattr(settings, "autotask_default_ticket_status", None):
return jsonify({"status": "error", "message": "Autotask default ticket status is not configured."}), 400
# Determine display status (including overrides) for consistent subject/priority mapping.
status_display = run.status or "-"
try:
status_display, _, _, _ov_id, _ov_reason = _apply_overrides_to_run(job, run)
except Exception:
status_display = run.status or "-"
severity = _determine_autotask_severity(status_display)
priority_id = None
if severity == "warning":
priority_id = getattr(settings, "autotask_priority_warning", None)
else:
priority_id = getattr(settings, "autotask_priority_error", None)
# Load mail + objects for ticket composition.
msg = MailMessage.query.get(run.mail_message_id) if run.mail_message_id else None
overall_message = (getattr(msg, "overall_message", None) or "") if msg else ""
objects_payload: list[dict[str, str]] = []
try:
objs = run.objects.order_by(JobObject.object_name.asc()).all()
except Exception:
objs = list(run.objects or [])
for o in objs or []:
objects_payload.append(
{
"name": getattr(o, "object_name", "") or "",
"type": getattr(o, "object_type", "") or "",
"status": getattr(o, "status", "") or "",
"error_message": getattr(o, "error_message", "") or "",
}
)
if (not objects_payload) and msg:
try:
mos = MailObject.query.filter_by(mail_message_id=msg.id).order_by(MailObject.object_name.asc()).all()
except Exception:
mos = []
for mo in mos or []:
objects_payload.append(
{
"name": getattr(mo, "object_name", "") or "",
"type": getattr(mo, "object_type", "") or "",
"status": getattr(mo, "status", "") or "",
"error_message": getattr(mo, "error_message", "") or "",
}
)
subject = f"[Backupchecks] {customer.name} - {job.job_name or ''} - {status_display}"
description = _compose_autotask_ticket_description(
settings=settings,
job=job,
run=run,
status_display=status_display,
overall_message=overall_message,
objects_payload=objects_payload,
)
payload = {
"companyID": int(customer.autotask_company_id),
"title": subject,
"description": description,
"queueID": int(settings.autotask_default_queue_id),
"source": int(settings.autotask_default_ticket_source_id),
"status": int(settings.autotask_default_ticket_status),
}
if priority_id:
payload["priority"] = int(priority_id)
try:
client = _build_autotask_client_from_settings()
created = client.create_ticket(payload)
except Exception as exc:
return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400
ticket_id = created.get("id") if isinstance(created, dict) else None
ticket_number = None
if isinstance(created, dict):
ticket_number = created.get("ticketNumber") or created.get("number") or created.get("ticket_number")
if not ticket_id:
return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400
try:
run.autotask_ticket_id = int(ticket_id)
except Exception:
run.autotask_ticket_id = None
run.autotask_ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None
run.autotask_ticket_created_at = datetime.utcnow()
run.autotask_ticket_created_by_user_id = current_user.id
try:
db.session.add(run)
db.session.commit()
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": f"Failed to store ticket reference: {exc}"}), 500
return jsonify(
{
"status": "ok",
"ticket_id": int(run.autotask_ticket_id) if run.autotask_ticket_id else None,
"ticket_number": run.autotask_ticket_number or "",
"already_exists": False,
}
)
@main_bp.post("/api/run-checks/mark-reviewed")
@login_required
@roles_required("admin", "operator")

View File

@ -1,5 +1,7 @@
from .routes_shared import * # noqa: F401,F403
from .routes_shared import _get_database_size_bytes, _get_or_create_settings, _format_bytes, _get_free_disk_bytes, _log_admin_event
import json
from datetime import datetime
@main_bp.route("/settings/jobs/delete-all", methods=["POST"])
@login_required
@ -405,6 +407,8 @@ def settings():
section = (request.args.get("section") or "general").strip().lower() or "general"
if request.method == "POST":
autotask_form_touched = any(str(k).startswith("autotask_") for k in (request.form or {}).keys())
# NOTE: The Settings UI has multiple tabs with separate forms.
# Only update values that are present in the submitted form, to avoid
# clearing unrelated settings when saving from another tab.
@ -430,6 +434,61 @@ def settings():
if "ui_timezone" in request.form:
settings.ui_timezone = (request.form.get("ui_timezone") or "").strip() or "Europe/Amsterdam"
# Autotask integration
if "autotask_enabled" in request.form:
settings.autotask_enabled = bool(request.form.get("autotask_enabled"))
if "autotask_environment" in request.form:
env_val = (request.form.get("autotask_environment") or "").strip().lower()
if env_val in ("sandbox", "production"):
settings.autotask_environment = env_val
else:
settings.autotask_environment = None
if "autotask_api_username" in request.form:
settings.autotask_api_username = (request.form.get("autotask_api_username") or "").strip() or None
if "autotask_api_password" in request.form:
pw = (request.form.get("autotask_api_password") or "").strip()
if pw:
settings.autotask_api_password = pw
if "autotask_tracking_identifier" in request.form:
settings.autotask_tracking_identifier = (request.form.get("autotask_tracking_identifier") or "").strip() or None
if "autotask_base_url" in request.form:
settings.autotask_base_url = (request.form.get("autotask_base_url") or "").strip() or None
if "autotask_default_queue_id" in request.form:
try:
settings.autotask_default_queue_id = int(request.form.get("autotask_default_queue_id") or 0) or None
except (ValueError, TypeError):
pass
if "autotask_default_ticket_source_id" in request.form:
try:
settings.autotask_default_ticket_source_id = int(request.form.get("autotask_default_ticket_source_id") or 0) or None
except (ValueError, TypeError):
pass
if "autotask_default_ticket_status" in request.form:
try:
settings.autotask_default_ticket_status = int(request.form.get("autotask_default_ticket_status") or 0) or None
except (ValueError, TypeError):
pass
if "autotask_priority_warning" in request.form:
try:
settings.autotask_priority_warning = int(request.form.get("autotask_priority_warning") or 0) or None
except (ValueError, TypeError):
pass
if "autotask_priority_error" in request.form:
try:
settings.autotask_priority_error = int(request.form.get("autotask_priority_error") or 0) or None
except (ValueError, TypeError):
pass
# Daily Jobs
if "daily_jobs_start_date" in request.form:
daily_jobs_start_date_str = (request.form.get("daily_jobs_start_date") or "").strip()
@ -506,6 +565,48 @@ def settings():
db.session.commit()
flash("Settings have been saved.", "success")
# Autotask ticket defaults depend on reference data (queues, sources, statuses, priorities).
# When the Autotask integration is (re)configured, auto-refresh the cached reference data
# once so the dropdowns become usable immediately.
try:
if (
autotask_form_touched
and bool(getattr(settings, "autotask_enabled", False))
and bool(getattr(settings, "autotask_api_username", None))
and bool(getattr(settings, "autotask_api_password", None))
and bool(getattr(settings, "autotask_tracking_identifier", None))
):
missing_cache = (
not bool(getattr(settings, "autotask_cached_queues_json", None))
or not bool(getattr(settings, "autotask_cached_ticket_sources_json", None))
or not bool(getattr(settings, "autotask_cached_ticket_statuses_json", None))
or not bool(getattr(settings, "autotask_cached_priorities_json", None))
)
if missing_cache:
queues, sources, statuses, pr_out = _refresh_autotask_reference_data(settings)
db.session.commit()
flash(
f"Autotask reference data refreshed. Queues: {len(queues)}. Ticket Sources: {len(sources)}. Ticket Statuses: {len(statuses)}. Priorities: {len(pr_out)}.",
"success",
)
_log_admin_event(
"autotask_reference_data_auto_refreshed",
"Autotask reference data auto-refreshed after settings save.",
details=json.dumps({"queues": len(queues or []), "ticket_sources": len(sources or []), "ticket_statuses": len(statuses or []), "priorities": len(pr_out)}),
)
except Exception as exc:
try:
db.session.rollback()
except Exception:
pass
flash(f"Autotask reference data refresh failed: {exc}", "warning")
_log_admin_event(
"autotask_reference_data_auto_refresh_failed",
"Autotask reference data auto-refresh failed after settings save.",
details=json.dumps({"error": str(exc)}),
)
# If EML storage has been turned off, clear any stored blobs immediately.
try:
if getattr(settings, "ingest_eml_retention_days", 7) == 0:
@ -537,6 +638,7 @@ def settings():
free_disk_warning = free_disk_bytes < two_gb
has_client_secret = bool(settings.graph_client_secret)
has_autotask_password = bool(getattr(settings, "autotask_api_password", None))
# Common UI timezones (IANA names)
tz_options = [
@ -595,6 +697,37 @@ def settings():
except Exception:
admin_users_count = 0
# Autotask cached reference data for dropdowns
autotask_queues = []
autotask_ticket_sources = []
autotask_priorities = []
autotask_ticket_statuses = []
autotask_last_sync_at = getattr(settings, "autotask_reference_last_sync_at", None)
try:
if getattr(settings, "autotask_cached_queues_json", None):
autotask_queues = json.loads(settings.autotask_cached_queues_json) or []
except Exception:
autotask_queues = []
try:
if getattr(settings, "autotask_cached_ticket_sources_json", None):
autotask_ticket_sources = json.loads(settings.autotask_cached_ticket_sources_json) or []
except Exception:
autotask_ticket_sources = []
try:
if getattr(settings, "autotask_cached_priorities_json", None):
autotask_priorities = json.loads(settings.autotask_cached_priorities_json) or []
except Exception:
autotask_priorities = []
try:
if getattr(settings, "autotask_cached_ticket_statuses_json", None):
autotask_ticket_statuses = json.loads(settings.autotask_cached_ticket_statuses_json) or []
except Exception:
autotask_ticket_statuses = []
return render_template(
"main/settings.html",
settings=settings,
@ -602,10 +735,16 @@ def settings():
free_disk_human=free_disk_human,
free_disk_warning=free_disk_warning,
has_client_secret=has_client_secret,
has_autotask_password=has_autotask_password,
tz_options=tz_options,
users=users,
admin_users_count=admin_users_count,
section=section,
autotask_queues=autotask_queues,
autotask_ticket_sources=autotask_ticket_sources,
autotask_priorities=autotask_priorities,
autotask_ticket_statuses=autotask_ticket_statuses,
autotask_last_sync_at=autotask_last_sync_at,
news_admin_items=news_admin_items,
news_admin_stats=news_admin_stats,
)
@ -1172,3 +1311,147 @@ def settings_folders():
except Exception:
pass
return jsonify({"status": "error", "message": str(exc) or "Failed to load folders."}), 500
@main_bp.route("/settings/autotask/test-connection", methods=["POST"])
@login_required
@roles_required("admin")
def settings_autotask_test_connection():
settings = _get_or_create_settings()
if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier:
flash("Autotask settings incomplete. Provide username, password and tracking identifier first.", "warning")
return redirect(url_for("main.settings", section="integrations"))
try:
from ..integrations.autotask.client import AutotaskClient
client = AutotaskClient(
username=settings.autotask_api_username,
password=settings.autotask_api_password,
api_integration_code=settings.autotask_tracking_identifier,
environment=(settings.autotask_environment or "production"),
)
zone = client.get_zone_info()
# Lightweight authenticated calls to validate credentials and basic API access
_ = client.get_queues()
_ = client.get_ticket_sources()
flash(f"Autotask connection OK. Zone: {zone.zone_name or 'unknown'}.", "success")
_log_admin_event(
"autotask_test_connection",
"Autotask test connection succeeded.",
details=json.dumps({"zone": zone.zone_name, "api_url": zone.api_url}),
)
except Exception as exc:
flash(f"Autotask connection failed: {exc}", "danger")
_log_admin_event(
"autotask_test_connection_failed",
"Autotask test connection failed.",
details=json.dumps({"error": str(exc)}),
)
return redirect(url_for("main.settings", section="integrations"))
def _refresh_autotask_reference_data(settings):
"""Refresh and persist Autotask reference data used for ticket default dropdowns."""
from ..integrations.autotask.client import AutotaskClient
client = AutotaskClient(
username=settings.autotask_api_username,
password=settings.autotask_api_password,
api_integration_code=settings.autotask_tracking_identifier,
environment=(settings.autotask_environment or "production"),
)
queues = client.get_queues()
sources = client.get_ticket_sources()
priorities = client.get_ticket_priorities()
statuses = client.get_ticket_statuses()
# Store a minimal subset for dropdowns (id + name/label)
# Note: Some "reference" values are exposed as picklists (value/label)
# instead of entity collections (id/name). We normalize both shapes.
def _norm(items):
out = []
for it in items or []:
if not isinstance(it, dict):
continue
_id = it.get("id")
if _id is None:
_id = it.get("value")
name = (
it.get("name")
or it.get("label")
or it.get("queueName")
or it.get("sourceName")
or it.get("description")
or ""
)
try:
_id_int = int(_id)
except Exception:
continue
out.append({"id": _id_int, "name": str(name)})
# Sort by name for stable dropdowns
out.sort(key=lambda x: (x.get("name") or "").lower())
return out
settings.autotask_cached_queues_json = json.dumps(_norm(queues))
settings.autotask_cached_ticket_sources_json = json.dumps(_norm(sources))
settings.autotask_cached_ticket_statuses_json = json.dumps(_norm(statuses))
# Priorities are returned as picklist values (value/label)
pr_out = []
for it in priorities or []:
if not isinstance(it, dict):
continue
if it.get("isActive") is False:
continue
val = it.get("value")
label = it.get("label") or it.get("name") or ""
try:
val_int = int(val)
except Exception:
continue
pr_out.append({"id": val_int, "name": str(label)})
pr_out.sort(key=lambda x: (x.get("name") or "").lower())
settings.autotask_cached_priorities_json = json.dumps(pr_out)
settings.autotask_reference_last_sync_at = datetime.utcnow()
return queues, sources, statuses, pr_out
@main_bp.route("/settings/autotask/refresh-reference-data", methods=["POST"])
@login_required
@roles_required("admin")
def settings_autotask_refresh_reference_data():
settings = _get_or_create_settings()
if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier:
flash("Autotask settings incomplete. Provide username, password and tracking identifier first.", "warning")
return redirect(url_for("main.settings", section="integrations"))
try:
queues, sources, statuses, pr_out = _refresh_autotask_reference_data(settings)
db.session.commit()
flash(
f"Autotask reference data refreshed. Queues: {len(queues)}. Ticket Sources: {len(sources)}. Ticket Statuses: {len(statuses)}. Priorities: {len(pr_out)}.",
"success",
)
_log_admin_event(
"autotask_refresh_reference_data",
"Autotask reference data refreshed.",
details=json.dumps({"queues": len(queues or []), "ticket_sources": len(sources or []), "ticket_statuses": len(statuses or []), "priorities": len(pr_out)}),
)
except Exception as exc:
flash(f"Failed to refresh Autotask reference data: {exc}", "danger")
_log_admin_event(
"autotask_refresh_reference_data_failed",
"Autotask reference data refresh failed.",
details=json.dumps({"error": str(exc)}),
)
return redirect(url_for("main.settings", section="integrations"))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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