diff --git a/.last-branch b/.last-branch index 08b1612..21015a3 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260115-08-autotask-entityinfo-fields-shape-fix +v20260115-09-autotask-customer-company-mapping diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index 7f86fb6..6aa9981 100644 --- a/containers/backupchecks/src/backend/app/integrations/autotask/client.py +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -14,7 +14,9 @@ class AutotaskZoneInfo: class AutotaskError(RuntimeError): - pass + def __init__(self, message: str, status_code: Optional[int] = None) -> None: + super().__init__(message) + self.status_code = status_code class AutotaskClient: @@ -141,13 +143,18 @@ class AutotaskClient: "Authentication failed (HTTP 401). " "Verify API Username, API Secret, and ApiIntegrationCode. " f"Environment={self.environment}, ZoneInfoBase={zi_base}, ZoneApiUrl={zone.api_url}." + , + status_code=401, ) if resp.status_code == 403: - raise AutotaskError("Access forbidden (HTTP 403). API user permissions may be insufficient.") + raise AutotaskError( + "Access forbidden (HTTP 403). API user permissions may be insufficient.", + status_code=403, + ) if resp.status_code == 404: - raise AutotaskError(f"Resource not found (HTTP 404) for path: {path}") + raise AutotaskError(f"Resource not found (HTTP 404) for path: {path}", status_code=404) if resp.status_code >= 400: - raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).") + raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).", status_code=resp.status_code) try: return resp.json() @@ -225,6 +232,58 @@ class AutotaskClient: """ return self._get_ticket_picklist_values(field_names=["source", "sourceid"]) + def search_companies(self, query: str, limit: int = 25) -> List[Dict[str, Any]]: + """Search Companies by company name. + + Uses the standard REST query endpoint: + GET /Companies/query?search={...} + + Returns a minimal list of dicts with keys: id, companyName, isActive. + """ + + q = (query or "").strip() + if not q: + return [] + + # Keep payload small and predictable. + # Field names in filters are case-insensitive in many tenants, but the docs + # commonly show CompanyName. + search_payload: Dict[str, Any] = { + "filter": [ + {"op": "contains", "field": "CompanyName", "value": q}, + ], + "maxRecords": int(limit) if int(limit) > 0 else 25, + } + + params = {"search": json.dumps(search_payload)} + data = self._request("GET", "Companies/query", params=params) + items = self._as_items_list(data) + + out: List[Dict[str, Any]] = [] + for it in items: + if not isinstance(it, dict): + continue + cid = it.get("id") + name = it.get("companyName") or it.get("CompanyName") or "" + try: + cid_int = int(cid) + except Exception: + continue + out.append( + { + "id": cid_int, + "companyName": str(name), + "isActive": bool(it.get("isActive", True)), + } + ) + + out.sort(key=lambda x: (x.get("companyName") or "").lower()) + return out + + def get_company(self, company_id: int) -> Dict[str, Any]: + """Fetch a single Company by ID.""" + return self._request("GET", f"Companies/{int(company_id)}") + def _get_ticket_picklist_values(self, field_names: List[str]) -> List[Dict[str, Any]]: """Retrieve picklist values for a Tickets field. diff --git a/containers/backupchecks/src/backend/app/main/routes_customers.py b/containers/backupchecks/src/backend/app/main/routes_customers.py index 688ecb0..9b01e06 100644 --- a/containers/backupchecks/src/backend/app/main/routes_customers.py +++ b/containers/backupchecks/src/backend/app/main/routes_customers.py @@ -6,6 +6,14 @@ from .routes_shared import * # noqa: F401,F403 def customers(): items = Customer.query.order_by(Customer.name.asc()).all() + settings = _get_or_create_settings() + autotask_enabled = bool(getattr(settings, "autotask_enabled", False)) + autotask_configured = bool( + (getattr(settings, "autotask_api_username", None)) + and (getattr(settings, "autotask_api_password", None)) + and (getattr(settings, "autotask_tracking_identifier", None)) + ) + rows = [] for c in items: # Count jobs linked to this customer @@ -19,6 +27,14 @@ def customers(): "name": c.name, "active": bool(c.active), "job_count": job_count, + "autotask_company_id": getattr(c, "autotask_company_id", None), + "autotask_company_name": getattr(c, "autotask_company_name", None), + "autotask_mapping_status": getattr(c, "autotask_mapping_status", None), + "autotask_last_sync_at": ( + getattr(c, "autotask_last_sync_at", None).isoformat(timespec="seconds") + if getattr(c, "autotask_last_sync_at", None) + else None + ), } ) @@ -28,9 +44,174 @@ def customers(): "main/customers.html", customers=rows, can_manage=can_manage, + autotask_enabled=autotask_enabled, + autotask_configured=autotask_configured, ) +def _get_autotask_client_or_raise(): + """Build an AutotaskClient from settings or raise a user-safe exception.""" + settings = _get_or_create_settings() + if not bool(getattr(settings, "autotask_enabled", False)): + raise RuntimeError("Autotask integration is disabled.") + if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier: + raise RuntimeError("Autotask settings incomplete.") + + from ..integrations.autotask.client import AutotaskClient + + return AutotaskClient( + username=settings.autotask_api_username, + password=settings.autotask_api_password, + api_integration_code=settings.autotask_tracking_identifier, + environment=(settings.autotask_environment or "production"), + ) + + +@main_bp.get("/api/autotask/companies/search") +@login_required +@roles_required("admin", "operator") +def api_autotask_companies_search(): + q = (request.args.get("q") or "").strip() + if not q: + return jsonify({"status": "ok", "items": []}) + + try: + client = _get_autotask_client_or_raise() + items = client.search_companies(q, limit=25) + return jsonify({"status": "ok", "items": items}) + except Exception as exc: + return jsonify({"status": "error", "message": str(exc) or "Search failed."}), 400 + + +def _normalize_company_name(company: dict) -> str: + return str(company.get("companyName") or company.get("CompanyName") or company.get("name") or "").strip() + + +@main_bp.get("/api/customers//autotask-mapping") +@login_required +@roles_required("admin", "operator", "viewer") +def api_customer_autotask_mapping_get(customer_id: int): + c = Customer.query.get_or_404(customer_id) + return jsonify( + { + "status": "ok", + "customer": { + "id": c.id, + "autotask_company_id": getattr(c, "autotask_company_id", None), + "autotask_company_name": getattr(c, "autotask_company_name", None), + "autotask_mapping_status": getattr(c, "autotask_mapping_status", None), + "autotask_last_sync_at": ( + getattr(c, "autotask_last_sync_at", None).isoformat(timespec="seconds") + if getattr(c, "autotask_last_sync_at", None) + else None + ), + }, + } + ) + + +@main_bp.post("/api/customers//autotask-mapping") +@login_required +@roles_required("admin", "operator") +def api_customer_autotask_mapping_set(customer_id: int): + c = Customer.query.get_or_404(customer_id) + payload = request.get_json(silent=True) or {} + company_id = payload.get("company_id") + try: + company_id_int = int(company_id) + except Exception: + return jsonify({"status": "error", "message": "Invalid company_id."}), 400 + + try: + client = _get_autotask_client_or_raise() + company = client.get_company(company_id_int) + name = _normalize_company_name(company) + + c.autotask_company_id = company_id_int + c.autotask_company_name = name + c.autotask_mapping_status = "ok" + c.autotask_last_sync_at = datetime.utcnow() + + db.session.commit() + return jsonify({"status": "ok"}) + except Exception as exc: + db.session.rollback() + return jsonify({"status": "error", "message": str(exc) or "Failed to set mapping."}), 400 + + +@main_bp.post("/api/customers//autotask-mapping/clear") +@login_required +@roles_required("admin", "operator") +def api_customer_autotask_mapping_clear(customer_id: int): + c = Customer.query.get_or_404(customer_id) + try: + c.autotask_company_id = None + c.autotask_company_name = None + c.autotask_mapping_status = None + c.autotask_last_sync_at = datetime.utcnow() + db.session.commit() + return jsonify({"status": "ok"}) + except Exception as exc: + db.session.rollback() + return jsonify({"status": "error", "message": str(exc) or "Failed to clear mapping."}), 400 + + +@main_bp.post("/api/customers//autotask-mapping/refresh") +@login_required +@roles_required("admin", "operator") +def api_customer_autotask_mapping_refresh(customer_id: int): + from ..integrations.autotask.client import AutotaskError + + c = Customer.query.get_or_404(customer_id) + company_id = getattr(c, "autotask_company_id", None) + if not company_id: + return jsonify({"status": "ok", "mapping_status": None}) + + try: + client = _get_autotask_client_or_raise() + company = client.get_company(int(company_id)) + name = _normalize_company_name(company) + + prev = (getattr(c, "autotask_company_name", None) or "").strip() + if prev and name and prev != name: + c.autotask_company_name = name + c.autotask_mapping_status = "renamed" + else: + c.autotask_company_name = name + c.autotask_mapping_status = "ok" + c.autotask_last_sync_at = datetime.utcnow() + + db.session.commit() + return jsonify({"status": "ok", "mapping_status": c.autotask_mapping_status, "company_name": c.autotask_company_name}) + except AutotaskError as exc: + try: + code = getattr(exc, "status_code", None) + except Exception: + code = None + + # 404 -> deleted/missing company in Autotask + if code == 404: + try: + c.autotask_mapping_status = "invalid" + c.autotask_last_sync_at = datetime.utcnow() + db.session.commit() + except Exception: + db.session.rollback() + return jsonify({"status": "ok", "mapping_status": "invalid"}) + + # Other errors: keep mapping but mark as missing (temporary/unreachable) + try: + c.autotask_mapping_status = "missing" + c.autotask_last_sync_at = datetime.utcnow() + db.session.commit() + except Exception: + db.session.rollback() + return jsonify({"status": "ok", "mapping_status": "missing", "message": str(exc)}) + except Exception as exc: + db.session.rollback() + return jsonify({"status": "error", "message": str(exc) or "Refresh failed."}), 400 + + @main_bp.route("/customers/create", methods=["POST"]) @login_required @roles_required("admin", "operator") diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 71a697e..6c21f5e 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -188,6 +188,41 @@ def migrate_system_settings_autotask_integration() -> None: print(f"[migrations] Failed to migrate system_settings autotask integration columns: {exc}") +def migrate_customers_autotask_company_mapping() -> None: + """Add Autotask company mapping columns to customers if missing. + + Columns: + - autotask_company_id (INTEGER NULL) + - autotask_company_name (VARCHAR(255) NULL) + - autotask_mapping_status (VARCHAR(20) NULL) + - autotask_last_sync_at (TIMESTAMP NULL) + """ + + table = "customers" + columns = [ + ("autotask_company_id", "INTEGER NULL"), + ("autotask_company_name", "VARCHAR(255) NULL"), + ("autotask_mapping_status", "VARCHAR(20) NULL"), + ("autotask_last_sync_at", "TIMESTAMP NULL"), + ] + + try: + engine = db.get_engine() + except Exception as exc: + print(f"[migrations] Could not get engine for customers autotask mapping migration: {exc}") + return + + try: + with engine.begin() as conn: + for column, ddl in columns: + if _column_exists_on_conn(conn, table, column): + continue + conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN {column} {ddl}')) + print("[migrations] migrate_customers_autotask_company_mapping completed.") + except Exception as exc: + print(f"[migrations] Failed to migrate customers autotask company mapping columns: {exc}") + + @@ -843,6 +878,7 @@ def run_migrations() -> None: migrate_system_settings_daily_jobs_start_date() migrate_system_settings_ui_timezone() migrate_system_settings_autotask_integration() + migrate_customers_autotask_company_mapping() migrate_mail_messages_columns() migrate_mail_messages_parse_columns() migrate_mail_messages_approval_columns() diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index 3aec257..e72a846 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -153,6 +153,14 @@ class Customer(db.Model): name = db.Column(db.String(255), unique=True, nullable=False) active = db.Column(db.Boolean, nullable=False, default=True) + # Autotask company mapping (Phase 3) + # Company ID is leading; name is cached for UI display. + autotask_company_id = db.Column(db.Integer, nullable=True) + autotask_company_name = db.Column(db.String(255), nullable=True) + # Mapping status: ok | renamed | missing | invalid + autotask_mapping_status = db.Column(db.String(20), nullable=True) + autotask_last_sync_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column( db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False diff --git a/containers/backupchecks/src/templates/main/customers.html b/containers/backupchecks/src/templates/main/customers.html index 153a546..91ec891 100644 --- a/containers/backupchecks/src/templates/main/customers.html +++ b/containers/backupchecks/src/templates/main/customers.html @@ -29,6 +29,8 @@ Customer Active Number of jobs + Autotask company + Autotask mapping {% if can_manage %} Actions {% endif %} @@ -46,6 +48,7 @@ Inactive {% endif %} + {% if c.job_count > 0 %} {{ c.job_count }} @@ -53,6 +56,36 @@ 0 {% endif %} + + + {% if c.autotask_company_id %} + {{ c.autotask_company_name or 'Unknown' }} +
ID: {{ c.autotask_company_id }}
+ {% else %} + Not mapped + {% endif %} + + + + {% set st = (c.autotask_mapping_status or '').lower() %} + {% if not c.autotask_company_id %} + Not mapped + {% elif st == 'ok' %} + OK + {% elif st == 'renamed' %} + Renamed + {% elif st == 'missing' %} + Missing + {% elif st == 'invalid' %} + Invalid + {% else %} + Unknown + {% endif %} + + {% if c.autotask_last_sync_at %} +
Checked: {{ c.autotask_last_sync_at }}
+ {% endif %} + {% if can_manage %} @@ -82,7 +119,7 @@ {% endfor %} {% else %} - + No customers found. @@ -130,6 +167,36 @@ Active + +
+ +
Autotask mapping
+ {% if autotask_enabled and autotask_configured %} +
+
Current mapping
+
Not mapped
+
+
+ +
+ + +
+ +
+ +
+ + + +
+ +
+ {% else %} +
+ Autotask integration is not available. Enable and configure it in Settings → Extensions & Integrations → Autotask. +
+ {% endif %}