diff --git a/.last-branch b/.last-branch index 2da1383..2133928 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260113-08-vspc-object-linking-normalize +v20260115-01-autotask-settings diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/__init__.py b/containers/backupchecks/src/backend/app/integrations/autotask/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py new file mode 100644 index 0000000..9eaa036 --- /dev/null +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -0,0 +1,129 @@ +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlencode + +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) -> Dict[str, 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 _query_all_first_page(self, entity_name: str) -> List[Dict[str, Any]]: + # Use a simple 'exist' filter on id to return the first page (up to 500 items). + search = {"filter": [{"op": "exist", "field": "id"}]} + params = {"search": json.dumps(search)} + data = self._request("GET", f"{entity_name}/query", params=params) + items = data.get("items") or [] + if not isinstance(items, list): + return [] + return items + + def get_queues(self) -> List[Dict[str, Any]]: + return self._query_all_first_page("Queues") + + def get_ticket_sources(self) -> List[Dict[str, Any]]: + return self._query_all_first_page("TicketSources") diff --git a/containers/backupchecks/src/backend/app/main/routes_settings.py b/containers/backupchecks/src/backend/app/main/routes_settings.py index 7018135..211be9a 100644 --- a/containers/backupchecks/src/backend/app/main/routes_settings.py +++ b/containers/backupchecks/src/backend/app/main/routes_settings.py @@ -1,5 +1,7 @@ from .routes_shared import * # noqa: F401,F403 from .routes_shared import _get_database_size_bytes, _get_or_create_settings, _format_bytes, _get_free_disk_bytes, _log_admin_event +import json +from datetime import datetime @main_bp.route("/settings/jobs/delete-all", methods=["POST"]) @login_required @@ -430,6 +432,61 @@ def settings(): if "ui_timezone" in request.form: settings.ui_timezone = (request.form.get("ui_timezone") or "").strip() or "Europe/Amsterdam" + # Autotask integration + if "autotask_enabled" in request.form: + settings.autotask_enabled = bool(request.form.get("autotask_enabled")) + + if "autotask_environment" in request.form: + env_val = (request.form.get("autotask_environment") or "").strip().lower() + if env_val in ("sandbox", "production"): + settings.autotask_environment = env_val + else: + settings.autotask_environment = None + + if "autotask_api_username" in request.form: + settings.autotask_api_username = (request.form.get("autotask_api_username") or "").strip() or None + + if "autotask_api_password" in request.form: + pw = (request.form.get("autotask_api_password") or "").strip() + if pw: + settings.autotask_api_password = pw + + if "autotask_tracking_identifier" in request.form: + settings.autotask_tracking_identifier = (request.form.get("autotask_tracking_identifier") or "").strip() or None + + if "autotask_base_url" in request.form: + settings.autotask_base_url = (request.form.get("autotask_base_url") or "").strip() or None + + if "autotask_default_queue_id" in request.form: + try: + settings.autotask_default_queue_id = int(request.form.get("autotask_default_queue_id") or 0) or None + except (ValueError, TypeError): + pass + + if "autotask_default_ticket_source_id" in request.form: + try: + settings.autotask_default_ticket_source_id = int(request.form.get("autotask_default_ticket_source_id") or 0) or None + except (ValueError, TypeError): + pass + + if "autotask_default_ticket_status" in request.form: + try: + settings.autotask_default_ticket_status = int(request.form.get("autotask_default_ticket_status") or 0) or None + except (ValueError, TypeError): + pass + + if "autotask_priority_warning" in request.form: + try: + settings.autotask_priority_warning = int(request.form.get("autotask_priority_warning") or 0) or None + except (ValueError, TypeError): + pass + + if "autotask_priority_error" in request.form: + try: + settings.autotask_priority_error = int(request.form.get("autotask_priority_error") or 0) or None + except (ValueError, TypeError): + pass + # Daily Jobs if "daily_jobs_start_date" in request.form: daily_jobs_start_date_str = (request.form.get("daily_jobs_start_date") or "").strip() @@ -537,6 +594,7 @@ def settings(): free_disk_warning = free_disk_bytes < two_gb has_client_secret = bool(settings.graph_client_secret) + has_autotask_password = bool(getattr(settings, "autotask_api_password", None)) # Common UI timezones (IANA names) tz_options = [ @@ -595,6 +653,23 @@ def settings(): except Exception: admin_users_count = 0 + # Autotask cached reference data for dropdowns + autotask_queues = [] + autotask_ticket_sources = [] + 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 = [] + return render_template( "main/settings.html", settings=settings, @@ -602,10 +677,14 @@ def settings(): free_disk_human=free_disk_human, free_disk_warning=free_disk_warning, has_client_secret=has_client_secret, + has_autotask_password=has_autotask_password, tz_options=tz_options, users=users, admin_users_count=admin_users_count, section=section, + autotask_queues=autotask_queues, + autotask_ticket_sources=autotask_ticket_sources, + autotask_last_sync_at=autotask_last_sync_at, news_admin_items=news_admin_items, news_admin_stats=news_admin_stats, ) @@ -1172,3 +1251,90 @@ def settings_folders(): except Exception: pass return jsonify({"status": "error", "message": str(exc) or "Failed to load folders."}), 500 + + +@main_bp.route("/settings/autotask/test-connection", methods=["POST"]) +@login_required +@roles_required("admin") +def settings_autotask_test_connection(): + settings = _get_or_create_settings() + + if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier: + flash("Autotask settings incomplete. Provide username, password and tracking identifier first.", "warning") + return redirect(url_for("main.settings", section="integrations")) + + try: + from ..integrations.autotask.client import AutotaskClient + client = AutotaskClient( + username=settings.autotask_api_username, + password=settings.autotask_api_password, + api_integration_code=settings.autotask_tracking_identifier, + environment=(settings.autotask_environment or "production"), + ) + zone = client.get_zone_info() + # Lightweight authenticated call to validate credentials + _ = client.get_ticket_sources() + flash(f"Autotask connection OK. Zone: {zone.zone_name or 'unknown'}.", "success") + _log_admin_event("autotask_test_connection", details={"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", details={"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() + + # Store a minimal subset for dropdowns (id + name/label) + def _norm(items): + out = [] + for it in items or []: + if not isinstance(it, dict): + continue + _id = it.get("id") + name = it.get("name") or it.get("label") or it.get("queueName") or it.get("sourceName") or it.get("description") or "" + try: + _id_int = int(_id) + except Exception: + continue + out.append({"id": _id_int, "name": str(name)}) + # Sort by name for stable dropdowns + out.sort(key=lambda x: (x.get("name") or "").lower()) + return out + + settings.autotask_cached_queues_json = json.dumps(_norm(queues)) + settings.autotask_cached_ticket_sources_json = json.dumps(_norm(sources)) + settings.autotask_reference_last_sync_at = datetime.utcnow() + + db.session.commit() + + flash(f"Autotask reference data refreshed. Queues: {len(queues)}. Ticket Sources: {len(sources)}.", "success") + _log_admin_event( + "autotask_refresh_reference_data", + details={"queues": len(queues or []), "ticket_sources": len(sources or [])}, + ) + except Exception as exc: + flash(f"Failed to refresh Autotask reference data: {exc}", "danger") + _log_admin_event("autotask_refresh_reference_data_failed", details={"error": str(exc)}) + + return redirect(url_for("main.settings", section="integrations")) diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 334be39..eae1fd4 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -127,6 +127,47 @@ def migrate_system_settings_ui_timezone() -> None: except Exception as exc: print(f"[migrations] Failed to migrate system_settings.ui_timezone: {exc}") +def migrate_system_settings_autotask_integration() -> None: + """Add Autotask integration columns to system_settings if missing.""" + + table = "system_settings" + + columns = [ + ("autotask_enabled", "BOOLEAN NOT NULL DEFAULT FALSE"), + ("autotask_environment", "VARCHAR(32) NULL"), + ("autotask_api_username", "VARCHAR(255) NULL"), + ("autotask_api_password", "VARCHAR(255) NULL"), + ("autotask_tracking_identifier", "VARCHAR(255) NULL"), + ("autotask_base_url", "VARCHAR(512) NULL"), + ("autotask_default_queue_id", "INTEGER NULL"), + ("autotask_default_ticket_source_id", "INTEGER NULL"), + ("autotask_default_ticket_status", "INTEGER NULL"), + ("autotask_priority_warning", "INTEGER NULL"), + ("autotask_priority_error", "INTEGER NULL"), + ("autotask_cached_queues_json", "TEXT NULL"), + ("autotask_cached_ticket_sources_json", "TEXT NULL"), + ("autotask_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: diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index 3d23da6..8aa2189 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -107,6 +107,26 @@ class SystemSettings(db.Model): # UI display timezone (IANA name). Used for rendering times in the web interface. ui_timezone = db.Column(db.String(64), nullable=False, default="Europe/Amsterdam") + + # Autotask integration settings + autotask_enabled = db.Column(db.Boolean, nullable=False, default=False) + autotask_environment = db.Column(db.String(32), nullable=True) # sandbox | production + autotask_api_username = db.Column(db.String(255), nullable=True) + autotask_api_password = db.Column(db.String(255), nullable=True) + autotask_tracking_identifier = db.Column(db.String(255), nullable=True) + autotask_base_url = db.Column(db.String(512), nullable=True) # Backupchecks base URL for deep links + + # Autotask defaults (IDs are leading) + autotask_default_queue_id = db.Column(db.Integer, nullable=True) + autotask_default_ticket_source_id = db.Column(db.Integer, nullable=True) + autotask_default_ticket_status = db.Column(db.Integer, nullable=True) + autotask_priority_warning = db.Column(db.Integer, nullable=True) + autotask_priority_error = db.Column(db.Integer, nullable=True) + + # Cached reference data (for dropdowns) + autotask_cached_queues_json = db.Column(db.Text, nullable=True) + autotask_cached_ticket_sources_json = db.Column(db.Text, nullable=True) + autotask_reference_last_sync_at = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column( db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False @@ -652,4 +672,4 @@ class ReportObjectSummary(db.Model): report = db.relationship( "ReportDefinition", backref=db.backref("object_summaries", lazy="dynamic", cascade="all, delete-orphan"), - ) \ No newline at end of file + ) diff --git a/containers/backupchecks/src/templates/main/settings.html b/containers/backupchecks/src/templates/main/settings.html index bdc5fbe..318ca9b 100644 --- a/containers/backupchecks/src/templates/main/settings.html +++ b/containers/backupchecks/src/templates/main/settings.html @@ -20,6 +20,9 @@