Compare commits

...

8 Commits

10 changed files with 1452 additions and 2 deletions

View File

@ -1 +1 @@
v20260113-08-vspc-object-linking-normalize v20260115-06-autotask-auth-fallback

View File

@ -0,0 +1,270 @@
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):
pass
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) -> 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,
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}."
)
if resp.status_code == 403:
raise AutotaskError("Access forbidden (HTTP 403). API user permissions may be insufficient.")
if resp.status_code == 404:
raise AutotaskError(f"Resource not found (HTTP 404) for path: {path}")
if resp.status_code >= 400:
raise AutotaskError(f"Autotask API error (HTTP {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)]
# 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 _get_ticket_picklist_values(self, field_names: List[str]) -> List[Dict[str, Any]]:
fields = self._get_entity_fields("Tickets")
wanted = {n.strip().lower() for n in (field_names or []) if str(n).strip()}
field: Optional[Dict[str, Any]] = None
for f in fields:
name = str(f.get("name") or "").strip().lower()
if name in wanted:
field = f
break
if not field:
raise AutotaskError(f"Unable to locate Tickets field metadata for picklist retrieval: {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_path = field.get("picklistValues")
if not isinstance(picklist_path, str) or not picklist_path.strip():
raise AutotaskError(f"Tickets.{field.get('name')} metadata did not include a picklistValues endpoint.")
return self._call_picklist_values(picklist_path)
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
for f in fields:
name = str(f.get("name") or "").strip().lower()
if name == "priority":
priority_field = f
break
if not priority_field:
raise AutotaskError("Unable to locate Tickets.priority field metadata for picklist retrieval.")
if not bool(priority_field.get("isPickList")):
raise AutotaskError("Tickets.priority is not marked as a picklist in Autotask metadata.")
picklist_path = priority_field.get("picklistValues")
if not isinstance(picklist_path, str) or not picklist_path.strip():
raise AutotaskError("Tickets.priority metadata did not include a picklistValues endpoint.")
return self._call_picklist_values(picklist_path)

View File

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

View File

@ -22,6 +22,27 @@ def _is_column_nullable(table_name: str, column_name: str) -> bool:
return False return False
def _column_exists_on_conn(conn, table_name: str, column_name: str) -> bool:
"""Return True if the given column exists using the provided connection.
This helper is useful inside engine.begin() blocks so we can check
column existence without creating a new inspector/connection.
"""
result = conn.execute(
text(
"""
SELECT 1
FROM information_schema.columns
WHERE table_name = :table
AND column_name = :column
LIMIT 1
"""
),
{"table": table_name, "column": column_name},
)
return result.first() is not None
def migrate_add_username_to_users() -> None: def migrate_add_username_to_users() -> None:
"""Ensure users.username column exists and is NOT NULL and UNIQUE. """Ensure users.username column exists and is NOT NULL and UNIQUE.
@ -127,6 +148,48 @@ def migrate_system_settings_ui_timezone() -> None:
except Exception as exc: except Exception as exc:
print(f"[migrations] Failed to migrate system_settings.ui_timezone: {exc}") print(f"[migrations] Failed to migrate system_settings.ui_timezone: {exc}")
def migrate_system_settings_autotask_integration() -> None:
"""Add Autotask integration columns to system_settings if missing."""
table = "system_settings"
columns = [
("autotask_enabled", "BOOLEAN NOT NULL DEFAULT FALSE"),
("autotask_environment", "VARCHAR(32) NULL"),
("autotask_api_username", "VARCHAR(255) NULL"),
("autotask_api_password", "VARCHAR(255) NULL"),
("autotask_tracking_identifier", "VARCHAR(255) NULL"),
("autotask_base_url", "VARCHAR(512) NULL"),
("autotask_default_queue_id", "INTEGER NULL"),
("autotask_default_ticket_source_id", "INTEGER NULL"),
("autotask_default_ticket_status", "INTEGER NULL"),
("autotask_priority_warning", "INTEGER NULL"),
("autotask_priority_error", "INTEGER NULL"),
("autotask_cached_queues_json", "TEXT NULL"),
("autotask_cached_ticket_sources_json", "TEXT NULL"),
("autotask_cached_priorities_json", "TEXT NULL"),
("autotask_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_mail_messages_columns() -> None: def migrate_mail_messages_columns() -> None:
@ -779,6 +842,7 @@ def run_migrations() -> None:
migrate_system_settings_auto_import_cutoff_date() migrate_system_settings_auto_import_cutoff_date()
migrate_system_settings_daily_jobs_start_date() migrate_system_settings_daily_jobs_start_date()
migrate_system_settings_ui_timezone() migrate_system_settings_ui_timezone()
migrate_system_settings_autotask_integration()
migrate_mail_messages_columns() migrate_mail_messages_columns()
migrate_mail_messages_parse_columns() migrate_mail_messages_parse_columns()
migrate_mail_messages_approval_columns() migrate_mail_messages_approval_columns()

View File

@ -107,6 +107,27 @@ class SystemSettings(db.Model):
# UI display timezone (IANA name). Used for rendering times in the web interface. # UI display timezone (IANA name). Used for rendering times in the web interface.
ui_timezone = db.Column(db.String(64), nullable=False, default="Europe/Amsterdam") ui_timezone = db.Column(db.String(64), nullable=False, default="Europe/Amsterdam")
# Autotask integration settings
autotask_enabled = db.Column(db.Boolean, nullable=False, default=False)
autotask_environment = db.Column(db.String(32), nullable=True) # sandbox | production
autotask_api_username = db.Column(db.String(255), nullable=True)
autotask_api_password = db.Column(db.String(255), nullable=True)
autotask_tracking_identifier = db.Column(db.String(255), nullable=True)
autotask_base_url = db.Column(db.String(512), nullable=True) # Backupchecks base URL for deep links
# Autotask defaults (IDs are leading)
autotask_default_queue_id = db.Column(db.Integer, nullable=True)
autotask_default_ticket_source_id = db.Column(db.Integer, nullable=True)
autotask_default_ticket_status = db.Column(db.Integer, nullable=True)
autotask_priority_warning = db.Column(db.Integer, nullable=True)
autotask_priority_error = db.Column(db.Integer, nullable=True)
# Cached reference data (for dropdowns)
autotask_cached_queues_json = db.Column(db.Text, nullable=True)
autotask_cached_ticket_sources_json = db.Column(db.Text, nullable=True)
autotask_cached_priorities_json = db.Column(db.Text, nullable=True)
autotask_reference_last_sync_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column( updated_at = db.Column(
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False

View File

@ -20,6 +20,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if section == 'imports' %}active{% endif %}" href="{{ url_for('main.settings', section='imports') }}">Imports</a> <a class="nav-link {% if section == 'imports' %}active{% endif %}" href="{{ url_for('main.settings', section='imports') }}">Imports</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if section == 'integrations' %}active{% endif %}" href="{{ url_for('main.settings', section='integrations') }}">Integrations</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if section == 'maintenance' %}active{% endif %}" href="{{ url_for('main.settings', section='maintenance') }}">Maintenance</a> <a class="nav-link {% if section == 'maintenance' %}active{% endif %}" href="{{ url_for('main.settings', section='maintenance') }}">Maintenance</a>
</li> </li>
@ -316,6 +319,151 @@
{% endif %} {% endif %}
{% if section == 'integrations' %}
<form method="post" class="mb-4">
<div class="card mb-3">
<div class="card-header">Autotask</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="autotask_enabled" name="autotask_enabled" {% if settings.autotask_enabled %}checked{% endif %} />
<label class="form-check-label" for="autotask_enabled">Enable Autotask integration</label>
</div>
<div class="row g-3">
<div class="col-md-4">
<label for="autotask_environment" class="form-label">Environment</label>
<select class="form-select" id="autotask_environment" name="autotask_environment">
<option value="" {% if not settings.autotask_environment %}selected{% endif %}>Select...</option>
<option value="sandbox" {% if settings.autotask_environment == 'sandbox' %}selected{% endif %}>Sandbox</option>
<option value="production" {% if settings.autotask_environment == 'production' %}selected{% endif %}>Production</option>
</select>
<div class="form-text">Use Sandbox for testing first.</div>
</div>
<div class="col-md-4">
<label for="autotask_api_username" class="form-label">API Username</label>
<input type="text" class="form-control" id="autotask_api_username" name="autotask_api_username" value="{{ settings.autotask_api_username or '' }}" />
</div>
<div class="col-md-4">
<label for="autotask_api_password" class="form-label">API Password</label>
<input
type="password"
class="form-control"
id="autotask_api_password"
name="autotask_api_password"
placeholder="{% if has_autotask_password %}******** (stored){% else %}enter password{% endif %}"
/>
<div class="form-text">Leave empty to keep the existing password.</div>
</div>
<div class="col-md-6">
<label for="autotask_tracking_identifier" class="form-label">Tracking Identifier (Integration Code)</label>
<input type="text" class="form-control" id="autotask_tracking_identifier" name="autotask_tracking_identifier" value="{{ settings.autotask_tracking_identifier or '' }}" />
</div>
<div class="col-md-6">
<label for="autotask_base_url" class="form-label">Backupchecks Base URL</label>
<input type="text" class="form-control" id="autotask_base_url" name="autotask_base_url" value="{{ settings.autotask_base_url or '' }}" placeholder="https://backupchecks.example.com" />
<div class="form-text">Required later for creating stable links to Job Details pages.</div>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Ticket defaults</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label for="autotask_default_queue_id" class="form-label">Default Queue</label>
<select class="form-select" id="autotask_default_queue_id" name="autotask_default_queue_id">
<option value="" {% if not settings.autotask_default_queue_id %}selected{% endif %}>Select...</option>
{% for q in autotask_queues %}
<option value="{{ q.id }}" {% if settings.autotask_default_queue_id == q.id %}selected{% endif %}>{{ q.name }}</option>
{% endfor %}
</select>
<div class="form-text">Requires refreshed reference data.</div>
</div>
<div class="col-md-6">
<label for="autotask_default_ticket_source_id" class="form-label">Ticket Source</label>
<select class="form-select" id="autotask_default_ticket_source_id" name="autotask_default_ticket_source_id">
<option value="" {% if not settings.autotask_default_ticket_source_id %}selected{% endif %}>Select...</option>
{% for s in autotask_ticket_sources %}
<option value="{{ s.id }}" {% if settings.autotask_default_ticket_source_id == s.id %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
<div class="form-text">Requires refreshed reference data.</div>
</div>
<div class="col-md-6">
<label for="autotask_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 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, and Priorities from Autotask for dropdown usage.</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if section == 'maintenance' %} {% if section == 'maintenance' %}
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">

View File

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

View File

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

View File

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