Compare commits
7 Commits
v20260115-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ae66457415 | |||
| d54b5c1e5d | |||
| e3d109ed24 | |||
| d2f7618772 | |||
| a0d6b1e0d4 | |||
| 3a31b6c5d2 | |||
| 506e1f56cd |
@ -1 +1 @@
|
|||||||
v20260115-05-autotask-queues-picklist-fix
|
v20260113-08-vspc-object-linking-normalize
|
||||||
|
|||||||
@ -1,227 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
def _zoneinfo_base(self) -> str:
|
|
||||||
# Production zone lookup endpoint: webservices.autotask.net
|
|
||||||
# Sandbox is typically pre-release: webservices2.autotask.net
|
|
||||||
if self.environment == "sandbox":
|
|
||||||
return "https://webservices2.autotask.net/atservicesrest"
|
|
||||||
return "https://webservices.autotask.net/atservicesrest"
|
|
||||||
|
|
||||||
def get_zone_info(self) -> AutotaskZoneInfo:
|
|
||||||
if self._zone_info is not None:
|
|
||||||
return self._zone_info
|
|
||||||
|
|
||||||
url = f"{self._zoneinfo_base().rstrip('/')}/v1.0/zoneInformation"
|
|
||||||
params = {"user": self.username}
|
|
||||||
try:
|
|
||||||
resp = requests.get(url, params=params, timeout=self.timeout_seconds)
|
|
||||||
except Exception as exc:
|
|
||||||
raise AutotaskError(f"ZoneInformation request failed: {exc}") from exc
|
|
||||||
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise AutotaskError(f"ZoneInformation request failed (HTTP {resp.status_code}).")
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as exc:
|
|
||||||
raise AutotaskError("ZoneInformation response is not valid JSON.") from exc
|
|
||||||
|
|
||||||
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 for API-only users.
|
|
||||||
return {
|
|
||||||
"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('/')}"
|
|
||||||
try:
|
|
||||||
resp = requests.request(
|
|
||||||
method=method.upper(),
|
|
||||||
url=url,
|
|
||||||
headers=self._headers(),
|
|
||||||
params=params or None,
|
|
||||||
auth=(self.username, self.password),
|
|
||||||
timeout=self.timeout_seconds,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise AutotaskError(f"Request failed: {exc}") from exc
|
|
||||||
|
|
||||||
if resp.status_code == 401:
|
|
||||||
raise AutotaskError("Authentication failed (HTTP 401). Check username/password and APIIntegrationcode.")
|
|
||||||
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)
|
|
||||||
@ -1,7 +1,5 @@
|
|||||||
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
|
||||||
@ -432,61 +430,6 @@ 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()
|
||||||
@ -594,7 +537,6 @@ 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 = [
|
||||||
@ -653,30 +595,6 @@ 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,
|
||||||
@ -684,15 +602,10 @@ 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,
|
||||||
)
|
)
|
||||||
@ -1259,138 +1172,3 @@ 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"))
|
|
||||||
|
|||||||
@ -22,27 +22,6 @@ 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.
|
||||||
|
|
||||||
@ -148,48 +127,6 @@ 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:
|
||||||
@ -842,7 +779,6 @@ 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()
|
||||||
|
|||||||
@ -107,27 +107,6 @@ 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
|
||||||
@ -673,4 +652,4 @@ class ReportObjectSummary(db.Model):
|
|||||||
report = db.relationship(
|
report = db.relationship(
|
||||||
"ReportDefinition",
|
"ReportDefinition",
|
||||||
backref=db.backref("object_summaries", lazy="dynamic", cascade="all, delete-orphan"),
|
backref=db.backref("object_summaries", lazy="dynamic", cascade="all, delete-orphan"),
|
||||||
)
|
)
|
||||||
@ -20,9 +20,6 @@
|
|||||||
<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>
|
||||||
@ -319,151 +316,6 @@
|
|||||||
{% 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">
|
||||||
|
|||||||
@ -1,464 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
@ -1,205 +0,0 @@
|
|||||||
# Backupchecks – Autotask Integration
|
|
||||||
|
|
||||||
## Implementation Breakdown & Validation Plan
|
|
||||||
|
|
||||||
_Last updated: 2026-01-13_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Purpose of this document
|
|
||||||
|
|
||||||
This document describes the **logical breakdown of the Autotask integration into implementation phases**.
|
|
||||||
|
|
||||||
It is intended to:
|
|
||||||
- Provide context at the start of each development chat
|
|
||||||
- Keep focus on the **overall goal** while working step by step
|
|
||||||
- Ensure each phase is independently testable and verifiable
|
|
||||||
- Prevent scope creep during implementation
|
|
||||||
|
|
||||||
This document complements:
|
|
||||||
- *Backupchecks – Autotask Integration Functional Design (Phase 1)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Guiding implementation principles
|
|
||||||
|
|
||||||
- Implement in **small, validated steps**
|
|
||||||
- Each phase must be:
|
|
||||||
- Testable in isolation
|
|
||||||
- Reviewable without knowledge of later phases
|
|
||||||
- No UI or workflow assumptions beyond the current phase
|
|
||||||
- Sandbox-first development
|
|
||||||
- No breaking changes without explicit intent
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Implementation phases
|
|
||||||
|
|
||||||
### Phase 1 – Autotask integration foundation
|
|
||||||
|
|
||||||
**Goal:** Establish a reliable, testable Autotask integration layer.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Autotask client/service abstraction
|
|
||||||
- Authentication handling
|
|
||||||
- Tracking Identifier usage
|
|
||||||
- Environment selection (Sandbox / Production)
|
|
||||||
- Test connection functionality
|
|
||||||
- Fetch reference data:
|
|
||||||
- Queues
|
|
||||||
- Ticket Sources
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
- UI integration (except minimal test hooks)
|
|
||||||
- Ticket creation
|
|
||||||
- Customer mapping
|
|
||||||
|
|
||||||
Validation criteria:
|
|
||||||
- Successful authentication against Sandbox
|
|
||||||
- Reference data can be retrieved and parsed
|
|
||||||
- Clear error handling for auth and API failures
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2 – Settings integration
|
|
||||||
|
|
||||||
**Goal:** Persist and validate Autotask configuration in Backupchecks.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- New Settings section:
|
|
||||||
- Extensions & Integrations → Autotask
|
|
||||||
- Store:
|
|
||||||
- Enable/disable toggle
|
|
||||||
- Environment
|
|
||||||
- API credentials
|
|
||||||
- Tracking Identifier
|
|
||||||
- Backupchecks Base URL
|
|
||||||
- Ticket defaults (queue, source, priorities)
|
|
||||||
- Dropdowns populated from live Autotask reference data
|
|
||||||
- Test connection & refresh reference data actions
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
- Customer mapping
|
|
||||||
- Ticket creation
|
|
||||||
|
|
||||||
Validation criteria:
|
|
||||||
- Settings can be saved and reloaded
|
|
||||||
- Invalid configurations are blocked
|
|
||||||
- Reference data reflects Autotask configuration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3 – Customer to Autotask company mapping
|
|
||||||
|
|
||||||
**Goal:** Establish stable, ID-based customer mappings.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Customer screen enhancements
|
|
||||||
- Search/select Autotask companies
|
|
||||||
- Store company ID + cached name
|
|
||||||
- Detect and reflect renamed or deleted companies
|
|
||||||
- Mapping status indicators
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
- Ticket creation
|
|
||||||
- Run-level logic
|
|
||||||
|
|
||||||
Validation criteria:
|
|
||||||
- Mapping persists correctly
|
|
||||||
- Renaming in Autotask does not break linkage
|
|
||||||
- Deleted companies are detected and reported
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4 – Ticket creation from Run Checks
|
|
||||||
|
|
||||||
**Goal:** Allow operators to create Autotask tickets from Backupchecks runs.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- “Create Autotask ticket” action
|
|
||||||
- Ticket payload composition rules
|
|
||||||
- Priority mapping (Warning / Error)
|
|
||||||
- Queue, source, status defaults
|
|
||||||
- Job Details page link inclusion
|
|
||||||
- Store ticket ID and number
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
- Ticket resolution
|
|
||||||
- Linking existing tickets
|
|
||||||
|
|
||||||
Validation criteria:
|
|
||||||
- Exactly one ticket per run
|
|
||||||
- Tickets contain correct content and links
|
|
||||||
- No duplicate tickets can be created
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 5 – Ticket resolution flows
|
|
||||||
|
|
||||||
**Goal:** Safely resolve tickets from Backupchecks.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Resolve without time entries:
|
|
||||||
- Internal note
|
|
||||||
- Close ticket
|
|
||||||
- Resolve with time entries:
|
|
||||||
- Internal note only
|
|
||||||
- Ticket remains open
|
|
||||||
- All notes stored as internal/system notes
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
- Automatic resolution
|
|
||||||
- Time entry creation
|
|
||||||
|
|
||||||
Validation criteria:
|
|
||||||
- Time entry checks enforced
|
|
||||||
- Correct notes added in all scenarios
|
|
||||||
- Ticket status reflects expected behaviour
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 6 – Integration disable & compatibility behaviour
|
|
||||||
|
|
||||||
**Goal:** Ensure safe fallback and migration support.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Disable Autotask integration globally
|
|
||||||
- Restore manual ticket number workflow
|
|
||||||
- Optional compatibility mode:
|
|
||||||
- Allow manual ticket number entry while integration enabled
|
|
||||||
- Link existing Autotask tickets to runs
|
|
||||||
|
|
||||||
Validation criteria:
|
|
||||||
- No Autotask API calls when integration is disabled
|
|
||||||
- Existing data remains visible
|
|
||||||
- Operators can safely transition between workflows
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Usage in development chats
|
|
||||||
|
|
||||||
For each development chat:
|
|
||||||
- Include this document
|
|
||||||
- Include the Functional Design document
|
|
||||||
- Clearly state:
|
|
||||||
- Current phase
|
|
||||||
- Current branch name
|
|
||||||
- Provided source/zip (if applicable)
|
|
||||||
|
|
||||||
This ensures:
|
|
||||||
- Shared context
|
|
||||||
- Focused discussions
|
|
||||||
- Predictable progress
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Summary
|
|
||||||
|
|
||||||
This breakdown ensures the Autotask integration is:
|
|
||||||
- Predictable
|
|
||||||
- Auditable
|
|
||||||
- Incrementally delivered
|
|
||||||
- Easy to reason about during implementation
|
|
||||||
|
|
||||||
Each phase builds on the previous one without hidden dependencies.
|
|
||||||
|
|
||||||
@ -1,50 +1,3 @@
|
|||||||
## 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.
|
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user