Auto-commit local changes before build (2026-01-19 11:11:08)

This commit is contained in:
Ivo Oskamp 2026-01-19 11:11:08 +01:00
parent f8a57efee0
commit 82bdebb721
10 changed files with 52 additions and 1255 deletions

View File

@ -1 +1 @@
v20260116-12-autotask-ticket-sync-circular-import-fix v20260119-01-restoredto-v20260115-12-autotask-customers-refreshall-mappings

View File

@ -1,16 +1,10 @@
import json import json
import logging
import os
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import requests import requests
logger = logging.getLogger(__name__)
@dataclass @dataclass
class AutotaskZoneInfo: class AutotaskZoneInfo:
zone_name: str zone_name: str
@ -43,29 +37,6 @@ class AutotaskClient:
self._zone_info: Optional[AutotaskZoneInfo] = None self._zone_info: Optional[AutotaskZoneInfo] = None
self._zoneinfo_base_used: Optional[str] = None self._zoneinfo_base_used: Optional[str] = None
def _debug_enabled(self) -> bool:
"""Return True when verbose Autotask integration logging is enabled.
This is intentionally controlled via an environment variable to avoid
writing sensitive payloads to logs by default.
"""
return str(os.getenv("BACKUPCHECKS_AUTOTASK_DEBUG", "")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _safe_json_preview(self, data: Any, max_chars: int = 1200) -> str:
"""Serialize JSON-like data for logging, truncating large payloads."""
try:
s = json.dumps(data, ensure_ascii=False, default=str)
except Exception:
s = str(data)
if len(s) > max_chars:
return s[:max_chars] + ""
return s
def _zoneinfo_bases(self) -> List[str]: def _zoneinfo_bases(self) -> List[str]:
"""Return a list of zoneInformation base URLs to try. """Return a list of zoneInformation base URLs to try.
@ -134,20 +105,13 @@ class AutotaskClient:
"Accept": "application/json", "Accept": "application/json",
} }
def _request_raw( def _request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
self,
method: str,
path: str,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
) -> requests.Response:
"""Perform an Autotask REST API request and return the raw response."""
zone = self.get_zone_info() zone = self.get_zone_info()
base = zone.api_url.rstrip("/") base = zone.api_url.rstrip("/")
url = f"{base}/v1.0/{path.lstrip('/')}" url = f"{base}/v1.0/{path.lstrip('/')}"
headers = self._headers() headers = self._headers()
def do_request(use_basic_auth: bool, extra_headers: Optional[Dict[str, str]] = None) -> requests.Response: def do_request(use_basic_auth: bool, extra_headers: Optional[Dict[str, str]] = None):
h = dict(headers) h = dict(headers)
if extra_headers: if extra_headers:
h.update(extra_headers) h.update(extra_headers)
@ -156,7 +120,6 @@ class AutotaskClient:
url=url, url=url,
headers=h, headers=h,
params=params or None, params=params or None,
json=json_body if json_body is not None else None,
auth=(self.username, self.password) if use_basic_auth else None, auth=(self.username, self.password) if use_basic_auth else None,
timeout=self.timeout_seconds, timeout=self.timeout_seconds,
) )
@ -179,7 +142,8 @@ class AutotaskClient:
raise AutotaskError( raise AutotaskError(
"Authentication failed (HTTP 401). " "Authentication failed (HTTP 401). "
"Verify API Username, API Secret, and ApiIntegrationCode. " "Verify API Username, API Secret, and ApiIntegrationCode. "
f"Environment={self.environment}, ZoneInfoBase={zi_base}, ZoneApiUrl={zone.api_url}.", f"Environment={self.environment}, ZoneInfoBase={zi_base}, ZoneApiUrl={zone.api_url}."
,
status_code=401, status_code=401,
) )
if resp.status_code == 403: if resp.status_code == 403:
@ -192,19 +156,6 @@ class AutotaskClient:
if resp.status_code >= 400: if resp.status_code >= 400:
raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).", status_code=resp.status_code) raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).", status_code=resp.status_code)
return resp
def _request(
self,
method: str,
path: str,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
) -> Any:
resp = self._request_raw(method=method, path=path, params=params, json_body=json_body)
if not (resp.content or b""):
return {}
try: try:
return resp.json() return resp.json()
except Exception as exc: except Exception as exc:
@ -438,347 +389,3 @@ class AutotaskClient:
raise AutotaskError("Tickets.priority metadata did not include picklist values.") raise AutotaskError("Tickets.priority metadata did not include picklist values.")
return self._call_picklist_values(picklist_values) return self._call_picklist_values(picklist_values)
def get_ticket_statuses(self) -> List[Dict[str, Any]]:
"""Return Ticket Status picklist values.
We retrieve this from Tickets field metadata to avoid hardcoded status IDs.
"""
return self._get_ticket_picklist_values(field_names=["status", "statusid"])
def get_ticket(self, ticket_id: int) -> Dict[str, Any]:
"""Fetch a Ticket by ID via GET /Tickets/<id>."""
if not isinstance(ticket_id, int) or ticket_id <= 0:
raise AutotaskError("Invalid Autotask ticket id.")
data = self._request("GET", f"Tickets/{ticket_id}")
# Autotask commonly wraps single-entity GET results in an "item" object.
# Normalize to the entity dict so callers can access fields like "id" and
# "ticketNumber" without having to unwrap.
if isinstance(data, dict) and data:
if isinstance(data.get("item"), dict) and data.get("item"):
return data["item"]
# Some endpoints/tenants may return a list even for a single ID.
if isinstance(data.get("items"), list) and data.get("items"):
first = data.get("items")[0]
if isinstance(first, dict) and first:
return first
return data
raise AutotaskError("Autotask did not return a ticket object.")
def query_tickets_by_ids(
self,
ticket_ids: List[int],
*,
max_records_per_query: int = 200,
corr_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Fetch multiple Tickets by id.
Preferred path:
- Use GET Tickets/query with an 'in' filter over id.
Fallback path:
- If the tenant does not support 'in' queries, fetch tickets individually
via GET Tickets/<id>.
Returns a list of ticket objects (dicts) for tickets that exist.
"""
# Normalize ids
ids: List[int] = []
for x in ticket_ids or []:
try:
xi = int(x)
except Exception:
continue
if xi > 0:
ids.append(xi)
# De-duplicate while preserving order
seen = set()
dedup: List[int] = []
for xi in ids:
if xi in seen:
continue
seen.add(xi)
dedup.append(xi)
if not dedup:
return []
corr = corr_id or uuid.uuid4().hex[:10]
def _chunk(lst: List[int], n: int) -> List[List[int]]:
return [lst[i : i + n] for i in range(0, len(lst), n)]
out: List[Dict[str, Any]] = []
# Try query with op=in first (chunked)
try:
for chunk in _chunk(dedup, max(1, int(max_records_per_query))):
search_payload: Dict[str, Any] = {
"filter": [
{"op": "in", "field": "id", "value": chunk},
],
"maxRecords": len(chunk),
}
params = {"search": json.dumps(search_payload)}
if self._debug_enabled():
logger.info(
"[autotask][%s] Tickets/query ids payload=%s",
corr,
self._safe_json_preview(search_payload, max_chars=1200),
)
data = self._request("GET", "Tickets/query", params=params)
items = self._as_items_list(data)
for it in items:
if isinstance(it, dict) and it:
out.append(it)
return out
except AutotaskError as exc:
# Common tenant behavior: reject op=in with HTTP 400.
if self._debug_enabled():
logger.info(
"[autotask][%s] Tickets/query ids op=in failed; falling back to per-ticket GET. error=%s",
corr,
str(exc),
)
except Exception as exc:
if self._debug_enabled():
logger.info(
"[autotask][%s] Tickets/query ids unexpected error; falling back. error=%s",
corr,
str(exc),
)
# Fallback: individual GET calls (best-effort)
for tid in dedup:
try:
t = self.get_ticket(int(tid))
if isinstance(t, dict) and t:
out.append(t)
except AutotaskError as exc:
# 404 -> deleted/missing ticket, ignore
if getattr(exc, "status_code", None) == 404:
continue
# Any other error: continue best-effort
continue
except Exception:
continue
return out
def _lookup_created_ticket_id(
self,
tracking_identifier: str,
company_id: Optional[int] = None,
corr_id: Optional[str] = None,
) -> Optional[int]:
"""Lookup the most recently created ticket by tracking identifier.
Some Autotask tenants return an empty body and omit Location headers on
successful POST /Tickets calls. In that case, we must lookup the created
ticket deterministically via query.
We prefer filtering by CompanyID when available to reduce ambiguity.
"""
tid = (tracking_identifier or "").strip()
if not tid:
return None
filters: List[Dict[str, Any]] = [
{"op": "eq", "field": "TrackingIdentifier", "value": tid},
]
if isinstance(company_id, int) and company_id > 0:
filters.append({"op": "eq", "field": "CompanyID", "value": int(company_id)})
# Order by createDate desc when supported; fall back to id desc.
search_payload: Dict[str, Any] = {
"filter": filters,
"maxRecords": 1,
"orderby": [
{"field": "createDate", "direction": "desc"},
{"field": "id", "direction": "desc"},
],
}
params = {"search": json.dumps(search_payload)}
if self._debug_enabled():
logger.info(
"[autotask][%s] Tickets/query lookup payload=%s",
corr_id or "-",
self._safe_json_preview(search_payload, max_chars=1200),
)
data = self._request("GET", "Tickets/query", params=params)
items = self._as_items_list(data)
if self._debug_enabled():
logger.info(
"[autotask][%s] Tickets/query lookup result_count=%s keys=%s",
corr_id or "-",
len(items),
(sorted(list(items[0].keys())) if items and isinstance(items[0], dict) else None),
)
if not items:
return None
first = items[0]
if isinstance(first, dict) and str(first.get("id") or "").isdigit():
return int(first["id"])
return None
def create_ticket(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Create a Ticket in Autotask.
Uses POST /Tickets.
Returns the created ticket object (as returned by Autotask).
"""
if not isinstance(payload, dict) or not payload:
raise AutotaskError("Ticket payload is empty.")
corr_id = uuid.uuid4().hex[:10]
if self._debug_enabled():
# Avoid dumping full descriptions by default, but include key routing fields.
payload_keys = sorted(list(payload.keys()))
logger.info(
"[autotask][%s] POST /Tickets payload_keys=%s companyID=%s queueID=%s source=%s status=%s priority=%s trackingIdentifier=%s",
corr_id,
payload_keys,
payload.get("companyID") or payload.get("CompanyID") or payload.get("companyId"),
payload.get("queueID") or payload.get("QueueID") or payload.get("queueId"),
payload.get("source") or payload.get("Source") or payload.get("sourceId") or payload.get("sourceID"),
payload.get("status") or payload.get("Status") or payload.get("statusId") or payload.get("statusID"),
payload.get("priority") or payload.get("Priority"),
payload.get("trackingIdentifier") or payload.get("TrackingIdentifier"),
)
resp = self._request_raw("POST", "Tickets", json_body=payload)
if self._debug_enabled():
location = (resp.headers.get("Location") or resp.headers.get("location") or "").strip()
logger.info(
"[autotask][%s] POST /Tickets response http=%s content_type=%s content_length=%s location=%s",
corr_id,
resp.status_code,
(resp.headers.get("Content-Type") or resp.headers.get("content-type") or ""),
(len(resp.content or b"") if resp is not None else None),
location or None,
)
data: Any = {}
if resp.content:
try:
data = resp.json()
except Exception:
# Some tenants return an empty body or a non-JSON body on successful POST.
data = {}
if self._debug_enabled():
# Log a short preview of the raw body to understand tenant behaviour.
try:
body_preview = (resp.text or "")[:600]
except Exception:
body_preview = ""
logger.info(
"[autotask][%s] POST /Tickets non-JSON body preview=%s",
corr_id,
body_preview,
)
if self._debug_enabled():
logger.info(
"[autotask][%s] POST /Tickets parsed_json_type=%s json_preview=%s",
corr_id,
type(data).__name__,
self._safe_json_preview(data, max_chars=1200),
)
ticket_id: Optional[int] = None
# Autotask may return a lightweight create result like {"itemId": 12345}.
if isinstance(data, dict):
for key in ("itemId", "itemID", "id", "ticketId", "ticketID"):
if key in data and str(data.get(key) or "").isdigit():
ticket_id = int(data[key])
break
# Some variants wrap the created entity.
if ticket_id is None and "item" in data and isinstance(data.get("item"), dict):
item = data.get("item")
if "id" in item and str(item.get("id") or "").isdigit():
ticket_id = int(item["id"])
else:
return item
if ticket_id is None and "items" in data and isinstance(data.get("items"), list) and data.get("items"):
first = data.get("items")[0]
if isinstance(first, dict):
if "id" in first and str(first.get("id") or "").isdigit():
ticket_id = int(first["id"])
else:
return first
# Location header often contains the created entity URL.
if ticket_id is None:
location = (resp.headers.get("Location") or resp.headers.get("location") or "").strip()
if location:
try:
last = location.rstrip("/").split("/")[-1]
if last.isdigit():
ticket_id = int(last)
except Exception:
ticket_id = None
if self._debug_enabled():
logger.info(
"[autotask][%s] POST /Tickets extracted_ticket_id=%s",
corr_id,
ticket_id,
)
# If we have an ID, fetch the full ticket object so callers can reliably access ticketNumber etc.
if ticket_id is not None:
return self.get_ticket(ticket_id)
# Deterministic fallback: query by tracking identifier (+ company) if present.
tracking_identifier = (
payload.get("trackingIdentifier")
or payload.get("TrackingIdentifier")
or ""
)
company_id: Optional[int] = None
for ck in ("companyID", "companyId", "CompanyID"):
if str(payload.get(ck) or "").isdigit():
company_id = int(payload[ck])
break
if self._debug_enabled():
logger.info(
"[autotask][%s] fallback lookup by TrackingIdentifier=%s companyID=%s",
corr_id,
str(tracking_identifier),
company_id,
)
looked_up_id = self._lookup_created_ticket_id(
str(tracking_identifier),
company_id=company_id,
corr_id=corr_id,
)
if looked_up_id is not None:
return self.get_ticket(looked_up_id)
# Last-resort fallback: normalize first item if possible.
items = self._as_items_list(data)
if items:
return items[0]
raise AutotaskError(
"Autotask did not return a ticket id. "
"Ticket creation may still have succeeded. "
f"(HTTP {resp.status_code}, Correlation={corr_id})."
)

View File

@ -16,7 +16,6 @@ from .parsers import parse_mail_message
from .parsers.veeam import extract_vspc_active_alarms_companies from .parsers.veeam import extract_vspc_active_alarms_companies
from .email_utils import normalize_from_address, extract_best_html_from_eml, is_effectively_blank_html from .email_utils import normalize_from_address, extract_best_html_from_eml, is_effectively_blank_html
from .job_matching import find_matching_job from .job_matching import find_matching_job
from .ticketing_utils import link_open_internal_tickets_to_run
GRAPH_TOKEN_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" GRAPH_TOKEN_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
@ -335,12 +334,6 @@ def _store_messages(settings: SystemSettings, messages):
db.session.add(run) db.session.add(run)
db.session.flush() db.session.flush()
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
auto_approved_runs.append((job.customer_id, job.id, run.id, mail.id)) auto_approved_runs.append((job.customer_id, job.id, run.id, mail.id))
created_any = True created_any = True
@ -391,12 +384,6 @@ def _store_messages(settings: SystemSettings, messages):
db.session.add(run) db.session.add(run)
db.session.flush() # ensure run.id is available db.session.flush() # ensure run.id is available
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
# Update mail message to reflect approval # Update mail message to reflect approval
mail.job_id = job.id mail.job_id = job.id
if hasattr(mail, "approved"): if hasattr(mail, "approved"):

View File

@ -4,7 +4,6 @@ from .routes_shared import _format_datetime, _log_admin_event, _send_mail_messag
from ..email_utils import extract_best_html_from_eml, is_effectively_blank_html from ..email_utils import extract_best_html_from_eml, is_effectively_blank_html
from ..parsers.veeam import extract_vspc_active_alarms_companies from ..parsers.veeam import extract_vspc_active_alarms_companies
from ..models import MailObject from ..models import MailObject
from ..ticketing_utils import link_open_internal_tickets_to_run
import time import time
import re import re
@ -296,13 +295,6 @@ def inbox_message_approve(message_id: int):
run.storage_free_percent = msg.storage_free_percent run.storage_free_percent = msg.storage_free_percent
db.session.add(run) db.session.add(run)
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
db.session.flush() # ensure run.id is available
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
# Update mail message to reflect approval # Update mail message to reflect approval
msg.job_id = job.id msg.job_id = job.id
if hasattr(msg, "approved"): if hasattr(msg, "approved"):
@ -546,12 +538,6 @@ def inbox_message_approve_vspc_companies(message_id: int):
db.session.add(run) db.session.add(run)
db.session.flush() db.session.flush()
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
created_runs.append(run) created_runs.append(run)
# Persist objects for reporting (idempotent upsert; safe to repeat). # Persist objects for reporting (idempotent upsert; safe to repeat).
@ -699,12 +685,6 @@ def inbox_message_approve_vspc_companies(message_id: int):
db.session.add(run2) db.session.add(run2)
db.session.flush() db.session.flush()
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run2, job=job2)
except Exception:
pass
# Persist objects per company # Persist objects per company
try: try:
persist_objects_for_approved_run_filtered( persist_objects_for_approved_run_filtered(
@ -1070,12 +1050,6 @@ def inbox_reparse_all():
db.session.add(run) db.session.add(run)
db.session.flush() db.session.flush()
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id)) auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
created_any = True created_any = True
@ -1136,12 +1110,6 @@ def inbox_reparse_all():
db.session.add(run) db.session.add(run)
db.session.flush() # ensure run.id is available db.session.flush() # ensure run.id is available
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id)) auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
msg.job_id = job.id msg.job_id = job.id
@ -1241,12 +1209,6 @@ def inbox_reparse_all():
db.session.add(run) db.session.add(run)
db.session.flush() db.session.flush()
# Legacy ticket behavior: inherit any open internal tickets for this job.
try:
link_open_internal_tickets_to_run(run=run, job=job)
except Exception:
pass
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id)) auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
msg.job_id = job.id msg.job_id = job.id

View File

@ -4,8 +4,7 @@ import calendar
from datetime import date, datetime, time, timedelta, timezone from datetime import date, datetime, time, timedelta, timezone
from flask import jsonify, render_template, request, url_for from flask import jsonify, render_template, request
from urllib.parse import urljoin
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import and_, or_, func, text from sqlalchemy import and_, or_, func, text
@ -32,119 +31,10 @@ from ..models import (
JobRunReviewEvent, JobRunReviewEvent,
MailMessage, MailMessage,
MailObject, MailObject,
Ticket,
TicketJobRun,
TicketScope,
Override, Override,
User, User,
) )
from ..ticketing_utils import (
ensure_internal_ticket_for_job,
ensure_ticket_jobrun_links,
link_open_internal_tickets_to_run,
)
def _build_autotask_client_from_settings():
"""Build an AutotaskClient from stored settings or raise a user-safe exception."""
settings = _get_or_create_settings()
if not getattr(settings, "autotask_enabled", False):
raise RuntimeError("Autotask integration is disabled.")
required = [
getattr(settings, "autotask_environment", None),
getattr(settings, "autotask_api_username", None),
getattr(settings, "autotask_api_password", None),
getattr(settings, "autotask_tracking_identifier", None),
]
if any(not (x and str(x).strip()) for x in required):
raise RuntimeError("Autotask settings incomplete.")
from ..integrations.autotask.client import AutotaskClient
return AutotaskClient(
username=settings.autotask_api_username,
password=settings.autotask_api_password,
api_integration_code=settings.autotask_tracking_identifier,
environment=settings.autotask_environment,
)
def _determine_autotask_severity(status_text: str | None) -> str:
s = (status_text or "").strip().lower()
if "warning" in s:
return "warning"
if "error" in s or "fail" in s:
return "error"
if "missed" in s:
return "error"
return "warning"
def _compose_autotask_ticket_description(
*,
settings,
job: Job,
run: JobRun,
status_display: str,
overall_message: str,
objects_payload: list[dict[str, str]],
) -> str:
tz_name = _get_ui_timezone_name() or "Europe/Amsterdam"
run_dt = run.run_at
run_at_str = _format_datetime(run_dt) if run_dt else "-"
base_url = (getattr(settings, "autotask_base_url", None) or "").strip()
job_rel = url_for("main.job_detail", job_id=job.id)
# Link to Job Details with a hint for the specific run.
job_link = urljoin(base_url.rstrip("/") + "/", job_rel.lstrip("/"))
if run.id:
job_link = f"{job_link}?run_id={int(run.id)}"
lines: list[str] = []
lines.append(f"Customer: {job.customer.name if job.customer else ''}")
lines.append(f"Job: {job.job_name or ''}")
lines.append(f"Backup: {job.backup_software or ''} / {job.backup_type or ''}")
lines.append(f"Run at ({tz_name}): {run_at_str}")
lines.append(f"Status: {status_display or ''}")
lines.append("")
overall_message = (overall_message or "").strip()
if overall_message:
lines.append("Summary:")
lines.append(overall_message)
lines.append("")
lines.append("Multiple objects reported messages. See Backupchecks for full details.")
else:
# Fallback to object-level messages with a hard limit.
limit = 10
shown = 0
total = 0
for o in objects_payload or []:
name = (o.get("name") or "").strip()
err = (o.get("error_message") or "").strip()
st = (o.get("status") or "").strip()
if not name:
continue
if not err and not st:
continue
total += 1
if shown >= limit:
continue
msg = err or st
lines.append(f"- {name}: {msg}")
shown += 1
if total == 0:
lines.append("No detailed object messages available. See Backupchecks for full details.")
elif total > shown:
lines.append(f"And {int(total - shown)} additional objects reported similar messages.")
lines.append("")
lines.append(f"Backupchecks details: {job_link}")
return "\n".join(lines).strip() + "\n"
# Grace window for matching real runs to an expected schedule slot. # Grace window for matching real runs to an expected schedule slot.
# A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot. # A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot.
MISSED_GRACE_WINDOW = timedelta(hours=1) MISSED_GRACE_WINDOW = timedelta(hours=1)
@ -316,11 +206,6 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date)
mail_message_id=None, mail_message_id=None,
) )
db.session.add(miss) db.session.add(miss)
try:
db.session.flush() # ensure miss.id is available
link_open_internal_tickets_to_run(run=miss, job=job)
except Exception:
pass
inserted += 1 inserted += 1
d = d + timedelta(days=1) d = d + timedelta(days=1)
@ -402,11 +287,6 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date)
mail_message_id=None, mail_message_id=None,
) )
db.session.add(miss) db.session.add(miss)
try:
db.session.flush() # ensure miss.id is available
link_open_internal_tickets_to_run(run=miss, job=job)
except Exception:
pass
inserted += 1 inserted += 1
# Next month # Next month
@ -873,8 +753,6 @@ def run_checks_details():
"mail": mail_meta, "mail": mail_meta,
"body_html": body_html, "body_html": body_html,
"objects": objects_payload, "objects": objects_payload,
"autotask_ticket_id": getattr(run, "autotask_ticket_id", None),
"autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "",
} }
) )
@ -892,468 +770,6 @@ def run_checks_details():
return jsonify({"status": "ok", "job": job_payload, "runs": runs_payload}) return jsonify({"status": "ok", "job": job_payload, "runs": runs_payload})
@main_bp.get("/api/run-checks/autotask-ticket-poll")
@login_required
@roles_required("admin", "operator")
def api_run_checks_autotask_ticket_poll():
"""Poll Autotask ticket state for Run Checks.
Notes:
- This endpoint does not mutate Autotask.
- As part of the legacy ticket workflow restoration, it may backfill
missing local metadata (ticket numbers) and internal Ticket/TicketJobRun
relations when those are absent.
"""
include_reviewed = False
if get_active_role() == "admin":
include_reviewed = request.args.get("include_reviewed", "0") in ("1", "true", "yes", "on")
# Only consider recently relevant runs to keep the payload small.
# We intentionally avoid unbounded history polling.
days = 60
try:
days = int(request.args.get("days", "60"))
except Exception:
days = 60
if days < 1:
days = 1
if days > 180:
days = 180
now_utc = datetime.utcnow().replace(tzinfo=None)
window_start = now_utc - timedelta(days=days)
q = JobRun.query.filter(JobRun.autotask_ticket_id.isnot(None))
if not include_reviewed:
q = q.filter(JobRun.reviewed_at.is_(None))
# Only poll runs in our time window.
q = q.filter(func.coalesce(JobRun.run_at, JobRun.created_at) >= window_start)
runs = (
q.order_by(func.coalesce(JobRun.run_at, JobRun.created_at).desc(), JobRun.id.desc())
.limit(400)
.all()
)
ticket_ids = []
seen = set()
for r in runs:
tid = getattr(r, "autotask_ticket_id", None)
try:
tid_int = int(tid)
except Exception:
continue
if tid_int <= 0 or tid_int in seen:
continue
seen.add(tid_int)
ticket_ids.append(tid_int)
if not ticket_ids:
return jsonify({"status": "ok", "tickets": []})
# If integration is disabled, do not fail the page.
settings = _get_or_create_settings()
if not getattr(settings, "autotask_enabled", False):
return jsonify({"status": "ok", "tickets": [], "autotask_enabled": False})
try:
client = _build_autotask_client_from_settings()
except Exception as exc:
return jsonify({"status": "ok", "tickets": [], "autotask_enabled": True, "message": str(exc)})
corr_id = datetime.utcnow().strftime("rcpoll-%Y%m%d%H%M%S")
# Query tickets in Autotask (best-effort)
tickets = []
try:
tickets = client.query_tickets_by_ids(ticket_ids, corr_id=corr_id)
except Exception:
tickets = []
# Build a minimal payload for UI use.
out = []
for t in tickets or []:
if not isinstance(t, dict):
continue
tid = t.get("id")
try:
tid_int = int(tid)
except Exception:
continue
out.append(
{
"id": tid_int,
"ticketNumber": (t.get("ticketNumber") or t.get("TicketNumber") or "") or "",
"status": t.get("status"),
"statusName": (t.get("statusName") or t.get("StatusName") or "") or "",
"title": (t.get("title") or t.get("Title") or "") or "",
"lastActivityDate": (t.get("lastActivityDate") or t.get("LastActivityDate") or t.get("lastActivity") or "") or "",
}
)
# Backfill local ticket numbers and internal Ticket/TicketJobRun records when missing.
# This is intentionally best-effort and must not break the Run Checks page.
try:
id_to_number = {}
id_to_title = {}
for item in out:
tid = item.get("id")
num = (item.get("ticketNumber") or "").strip()
if tid and num:
id_to_number[int(tid)] = num
id_to_title[int(tid)] = (item.get("title") or "").strip() or None
if id_to_number:
# Update JobRun.autotask_ticket_number if empty, and ensure internal ticket workflow exists.
jobs_seen = set()
for r in runs:
try:
tid = int(getattr(r, "autotask_ticket_id", None) or 0)
except Exception:
tid = 0
if tid <= 0 or tid not in id_to_number:
continue
number = id_to_number.get(tid)
if number and not ((getattr(r, "autotask_ticket_number", None) or "").strip()):
r.autotask_ticket_number = number
db.session.add(r)
# Ensure internal Ticket + scope + links exist (per job)
if r.job_id and int(r.job_id) not in jobs_seen:
jobs_seen.add(int(r.job_id))
job = Job.query.get(r.job_id)
if not job:
continue
ticket = ensure_internal_ticket_for_job(
ticket_code=number,
title=id_to_title.get(tid),
description=f"Autotask ticket {number}",
job=job,
active_from_dt=getattr(r, "run_at", None),
start_dt=datetime.utcnow(),
)
# Link all currently active (unreviewed) runs for this job.
run_ids = [
int(x)
for (x,) in (
JobRun.query.filter(JobRun.job_id == job.id)
.filter(JobRun.reviewed_at.is_(None))
.with_entities(JobRun.id)
.all()
)
if x is not None
]
ensure_ticket_jobrun_links(ticket_id=ticket.id, run_ids=run_ids, link_source="autotask")
db.session.commit()
except Exception:
try:
db.session.rollback()
except Exception:
pass
return jsonify({"status": "ok", "tickets": out, "autotask_enabled": True})
@main_bp.post("/api/run-checks/autotask-ticket")
@login_required
@roles_required("admin", "operator")
def api_run_checks_create_autotask_ticket():
"""Create an Autotask ticket for a specific run.
Enforces: exactly one ticket per run.
"""
data = request.get_json(silent=True) or {}
try:
run_id = int(data.get("run_id") or 0)
except Exception:
run_id = 0
if run_id <= 0:
return jsonify({"status": "error", "message": "Invalid parameters."}), 400
run = JobRun.query.get(run_id)
if not run:
return jsonify({"status": "error", "message": "Run not found."}), 404
# Idempotent behavior:
# If the run already has an Autotask ticket linked, we still continue so we can:
# - propagate the linkage to all active (non-reviewed) runs of the same job
# - synchronize the internal Ticket + TicketJobRun records (used by Tickets/Remarks + Job Details)
already_exists = False
existing_ticket_id = getattr(run, "autotask_ticket_id", None)
existing_ticket_number = (getattr(run, "autotask_ticket_number", None) or "").strip() or None
if existing_ticket_id:
already_exists = True
job = Job.query.get(run.job_id)
if not job:
return jsonify({"status": "error", "message": "Job not found."}), 404
customer = Customer.query.get(job.customer_id) if getattr(job, "customer_id", None) else None
if not customer:
return jsonify({"status": "error", "message": "Customer not found."}), 404
if not getattr(customer, "autotask_company_id", None):
return jsonify({"status": "error", "message": "Customer has no Autotask company mapping."}), 400
if (getattr(customer, "autotask_mapping_status", None) or "").strip().lower() not in ("ok", "renamed"):
return jsonify({"status": "error", "message": "Autotask company mapping is not valid."}), 400
settings = _get_or_create_settings()
base_url = (getattr(settings, "autotask_base_url", None) or "").strip()
if not base_url:
return jsonify({"status": "error", "message": "Autotask Base URL is not configured."}), 400
# Required ticket defaults
if not getattr(settings, "autotask_default_queue_id", None):
return jsonify({"status": "error", "message": "Autotask default queue is not configured."}), 400
if not getattr(settings, "autotask_default_ticket_source_id", None):
return jsonify({"status": "error", "message": "Autotask default ticket source is not configured."}), 400
if not getattr(settings, "autotask_default_ticket_status", None):
return jsonify({"status": "error", "message": "Autotask default ticket status is not configured."}), 400
# Determine display status (including overrides) for consistent subject/priority mapping.
status_display = run.status or "-"
try:
status_display, _, _, _ov_id, _ov_reason = _apply_overrides_to_run(job, run)
except Exception:
status_display = run.status or "-"
severity = _determine_autotask_severity(status_display)
priority_id = None
if severity == "warning":
priority_id = getattr(settings, "autotask_priority_warning", None)
else:
priority_id = getattr(settings, "autotask_priority_error", None)
# Load mail + objects for ticket composition.
msg = MailMessage.query.get(run.mail_message_id) if run.mail_message_id else None
overall_message = (getattr(msg, "overall_message", None) or "") if msg else ""
objects_payload: list[dict[str, str]] = []
try:
objs = run.objects.order_by(JobObject.object_name.asc()).all()
except Exception:
objs = list(run.objects or [])
for o in objs or []:
objects_payload.append(
{
"name": getattr(o, "object_name", "") or "",
"type": getattr(o, "object_type", "") or "",
"status": getattr(o, "status", "") or "",
"error_message": getattr(o, "error_message", "") or "",
}
)
if (not objects_payload) and msg:
try:
mos = MailObject.query.filter_by(mail_message_id=msg.id).order_by(MailObject.object_name.asc()).all()
except Exception:
mos = []
for mo in mos or []:
objects_payload.append(
{
"name": getattr(mo, "object_name", "") or "",
"type": getattr(mo, "object_type", "") or "",
"status": getattr(mo, "status", "") or "",
"error_message": getattr(mo, "error_message", "") or "",
}
)
subject = f"[Backupchecks] {customer.name} - {job.job_name or ''} - {status_display}"
description = _compose_autotask_ticket_description(
settings=settings,
job=job,
run=run,
status_display=status_display,
overall_message=overall_message,
objects_payload=objects_payload,
)
payload = {
"companyID": int(customer.autotask_company_id),
"title": subject,
"description": description,
"queueID": int(settings.autotask_default_queue_id),
"source": int(settings.autotask_default_ticket_source_id),
"status": int(settings.autotask_default_ticket_status),
}
if priority_id:
payload["priority"] = int(priority_id)
try:
client = _build_autotask_client_from_settings()
except Exception as exc:
return jsonify({"status": "error", "message": f"Autotask client initialization failed: {exc}"}), 400
ticket_id = None
ticket_number = None
if already_exists:
try:
ticket_id = int(existing_ticket_id)
except Exception:
ticket_id = None
ticket_number = existing_ticket_number
else:
try:
created = client.create_ticket(payload)
except Exception as exc:
return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400
if isinstance(created, dict):
ticket_id = created.get("id") or created.get("itemId") or created.get("ticketId")
try:
# Some wrappers return {"item": {"id": ...}}
if not ticket_id and isinstance(created.get("item"), dict):
ticket_id = created.get("item", {}).get("id")
except Exception:
pass
if not ticket_id:
return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400
# Autotask typically does not return the ticket number on create.
# Also, existing linkages may have ticket_id but no ticket_number yet.
# Always fetch the ticket if we don't have the number so we can persist it for UI and internal linking.
if ticket_id and not ticket_number:
try:
fetched = client.get_ticket(int(ticket_id))
if isinstance(fetched, dict) and isinstance(fetched.get("item"), dict):
fetched = fetched.get("item")
if isinstance(fetched, dict):
ticket_number = fetched.get("ticketNumber") or fetched.get("number") or fetched.get("ticket_number")
except Exception:
ticket_number = ticket_number or None
# Link the created Autotask ticket to all relevant open runs of the same job.
# This matches the manual ticket workflow where one ticket remains visible across runs
# until it is explicitly resolved.
now = datetime.utcnow()
# Collect the open run IDs first (stable list), then apply updates and internal linking.
linked_run_ids: list[int] = []
try:
rows = (
JobRun.query.filter(JobRun.job_id == run.job_id)
.filter(JobRun.reviewed_at.is_(None))
.with_entities(JobRun.id)
.order_by(JobRun.id.asc())
.all()
)
linked_run_ids = [int(rid) for (rid,) in rows if rid is not None]
except Exception:
linked_run_ids = []
# Safety: always include the explicitly selected run.
try:
if run.id and int(run.id) not in linked_run_ids:
linked_run_ids.append(int(run.id))
except Exception:
pass
# Load run objects for the IDs we determined.
open_runs = []
if linked_run_ids:
open_runs = JobRun.query.filter(JobRun.id.in_(linked_run_ids)).all()
else:
open_runs = [run]
try:
if run.id:
linked_run_ids = [int(run.id)]
except Exception:
linked_run_ids = []
for r in open_runs or []:
# Do not overwrite an existing (different) ticket linkage.
existing_id = getattr(r, "autotask_ticket_id", None)
if existing_id:
try:
if int(existing_id) != int(ticket_id):
continue
except Exception:
continue
try:
r.autotask_ticket_id = int(ticket_id)
except Exception:
r.autotask_ticket_id = None
r.autotask_ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None
r.autotask_ticket_created_at = now
r.autotask_ticket_created_by_user_id = current_user.id
# Also store an internal Ticket record and link it to all relevant active runs.
# This keeps Tickets/Remarks, Job Details, and Run Checks indicators consistent with the existing manual workflow,
# and remains functional even if PSA integration is disabled later.
internal_ticket = None
if ticket_number:
ticket_code = (str(ticket_number) or "").strip().upper()
internal_ticket = Ticket.query.filter_by(ticket_code=ticket_code).first()
if not internal_ticket:
internal_ticket = Ticket(
ticket_code=ticket_code,
title=subject,
description=description,
active_from_date=_to_amsterdam_date(run.run_at) or _to_amsterdam_date(now) or now.date(),
start_date=now,
resolved_at=None,
)
db.session.add(internal_ticket)
db.session.flush()
# Ensure a job scope exists (used by popups / job details / tickets page)
scope = None
if job and job.id and internal_ticket and internal_ticket.id:
scope = TicketScope.query.filter_by(ticket_id=internal_ticket.id, scope_type="job", job_id=job.id).first()
if not scope and internal_ticket and internal_ticket.id:
scope = TicketScope(
ticket_id=internal_ticket.id,
scope_type="job",
customer_id=job.customer_id if job else None,
backup_software=job.backup_software if job else None,
backup_type=job.backup_type if job else None,
job_id=job.id if job else None,
job_name_match=job.job_name if job else None,
job_name_match_mode="exact",
resolved_at=None,
)
db.session.add(scope)
elif scope:
scope.resolved_at = None
# Link ticket to all relevant active job runs (idempotent)
for rid in linked_run_ids or []:
if not TicketJobRun.query.filter_by(ticket_id=internal_ticket.id, job_run_id=rid).first():
db.session.add(TicketJobRun(ticket_id=internal_ticket.id, job_run_id=rid, link_source="autotask"))
try:
for r in open_runs or []:
db.session.add(r)
db.session.commit()
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": f"Failed to store ticket reference: {exc}"}), 500
return jsonify(
{
"status": "ok",
"ticket_id": int(run.autotask_ticket_id) if run.autotask_ticket_id else None,
"ticket_number": run.autotask_ticket_number or "",
"already_exists": bool(already_exists),
"linked_run_ids": linked_run_ids or [],
}
)
@main_bp.post("/api/run-checks/mark-reviewed") @main_bp.post("/api/run-checks/mark-reviewed")
@login_required @login_required
@roles_required("admin", "operator") @roles_required("admin", "operator")

View File

@ -657,7 +657,6 @@ def settings():
autotask_queues = [] autotask_queues = []
autotask_ticket_sources = [] autotask_ticket_sources = []
autotask_priorities = [] autotask_priorities = []
autotask_ticket_statuses = []
autotask_last_sync_at = getattr(settings, "autotask_reference_last_sync_at", None) autotask_last_sync_at = getattr(settings, "autotask_reference_last_sync_at", None)
try: try:
@ -678,12 +677,6 @@ def settings():
except Exception: except Exception:
autotask_priorities = [] autotask_priorities = []
try:
if getattr(settings, "autotask_cached_ticket_statuses_json", None):
autotask_ticket_statuses = json.loads(settings.autotask_cached_ticket_statuses_json) or []
except Exception:
autotask_ticket_statuses = []
return render_template( return render_template(
"main/settings.html", "main/settings.html",
settings=settings, settings=settings,
@ -699,7 +692,6 @@ def settings():
autotask_queues=autotask_queues, autotask_queues=autotask_queues,
autotask_ticket_sources=autotask_ticket_sources, autotask_ticket_sources=autotask_ticket_sources,
autotask_priorities=autotask_priorities, autotask_priorities=autotask_priorities,
autotask_ticket_statuses=autotask_ticket_statuses,
autotask_last_sync_at=autotask_last_sync_at, 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,
@ -1330,7 +1322,6 @@ def settings_autotask_refresh_reference_data():
queues = client.get_queues() queues = client.get_queues()
sources = client.get_ticket_sources() sources = client.get_ticket_sources()
priorities = client.get_ticket_priorities() priorities = client.get_ticket_priorities()
statuses = client.get_ticket_statuses()
# Store a minimal subset for dropdowns (id + name/label) # Store a minimal subset for dropdowns (id + name/label)
# Note: Some "reference" values are exposed as picklists (value/label) # Note: Some "reference" values are exposed as picklists (value/label)
@ -1363,7 +1354,6 @@ def settings_autotask_refresh_reference_data():
settings.autotask_cached_queues_json = json.dumps(_norm(queues)) settings.autotask_cached_queues_json = json.dumps(_norm(queues))
settings.autotask_cached_ticket_sources_json = json.dumps(_norm(sources)) settings.autotask_cached_ticket_sources_json = json.dumps(_norm(sources))
settings.autotask_cached_ticket_statuses_json = json.dumps(_norm(statuses))
# Priorities are returned as picklist values (value/label) # Priorities are returned as picklist values (value/label)
pr_out = [] pr_out = []
@ -1387,13 +1377,13 @@ def settings_autotask_refresh_reference_data():
db.session.commit() db.session.commit()
flash( flash(
f"Autotask reference data refreshed. Queues: {len(queues)}. Ticket Sources: {len(sources)}. Ticket Statuses: {len(statuses)}. Priorities: {len(pr_out)}.", f"Autotask reference data refreshed. Queues: {len(queues)}. Ticket Sources: {len(sources)}. Priorities: {len(pr_out)}.",
"success", "success",
) )
_log_admin_event( _log_admin_event(
"autotask_refresh_reference_data", "autotask_refresh_reference_data",
"Autotask reference data refreshed.", "Autotask reference data refreshed.",
details=json.dumps({"queues": len(queues or []), "ticket_sources": len(sources or []), "ticket_statuses": len(statuses or []), "priorities": len(pr_out)}), details=json.dumps({"queues": len(queues or []), "ticket_sources": len(sources or []), "priorities": len(pr_out)}),
) )
except Exception as exc: except Exception as exc:
flash(f"Failed to refresh Autotask reference data: {exc}", "danger") flash(f"Failed to refresh Autotask reference data: {exc}", "danger")

View File

@ -168,7 +168,6 @@ def migrate_system_settings_autotask_integration() -> None:
("autotask_cached_queues_json", "TEXT NULL"), ("autotask_cached_queues_json", "TEXT NULL"),
("autotask_cached_ticket_sources_json", "TEXT NULL"), ("autotask_cached_ticket_sources_json", "TEXT NULL"),
("autotask_cached_priorities_json", "TEXT NULL"), ("autotask_cached_priorities_json", "TEXT NULL"),
("autotask_cached_ticket_statuses_json", "TEXT NULL"),
("autotask_reference_last_sync_at", "TIMESTAMP NULL"), ("autotask_reference_last_sync_at", "TIMESTAMP NULL"),
] ]
@ -898,7 +897,6 @@ def run_migrations() -> None:
migrate_overrides_match_columns() migrate_overrides_match_columns()
migrate_job_runs_review_tracking() migrate_job_runs_review_tracking()
migrate_job_runs_override_metadata() migrate_job_runs_override_metadata()
migrate_job_runs_autotask_ticket_fields()
migrate_jobs_archiving() migrate_jobs_archiving()
migrate_news_tables() migrate_news_tables()
migrate_reporting_tables() migrate_reporting_tables()
@ -906,67 +904,6 @@ def run_migrations() -> None:
print("[migrations] All migrations completed.") print("[migrations] All migrations completed.")
def migrate_job_runs_autotask_ticket_fields() -> None:
"""Add Autotask ticket linkage fields to job_runs if missing.
Columns:
- job_runs.autotask_ticket_id (INTEGER NULL)
- job_runs.autotask_ticket_number (VARCHAR(64) NULL)
- job_runs.autotask_ticket_created_at (TIMESTAMP NULL)
- job_runs.autotask_ticket_created_by_user_id (INTEGER NULL, FK users.id)
"""
table = "job_runs"
try:
engine = db.get_engine()
except Exception as exc:
print(f"[migrations] Could not get engine for job_runs Autotask ticket migration: {exc}")
return
try:
with engine.connect() as conn:
cols = _get_table_columns(conn, table)
if not cols:
return
if "autotask_ticket_id" not in cols:
print("[migrations] Adding job_runs.autotask_ticket_id column...")
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_id INTEGER'))
if "autotask_ticket_number" not in cols:
print("[migrations] Adding job_runs.autotask_ticket_number column...")
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_number VARCHAR(64)'))
if "autotask_ticket_created_at" not in cols:
print("[migrations] Adding job_runs.autotask_ticket_created_at column...")
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_created_at TIMESTAMP'))
if "autotask_ticket_created_by_user_id" not in cols:
print("[migrations] Adding job_runs.autotask_ticket_created_by_user_id column...")
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_created_by_user_id INTEGER'))
try:
conn.execute(
text(
'ALTER TABLE "job_runs" '
'ADD CONSTRAINT job_runs_autotask_ticket_created_by_user_id_fkey '
'FOREIGN KEY (autotask_ticket_created_by_user_id) REFERENCES users(id) '
'ON DELETE SET NULL'
)
)
except Exception as exc:
print(
f"[migrations] Could not add FK job_runs.autotask_ticket_created_by_user_id -> users.id (continuing): {exc}"
)
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_id ON "job_runs" (autotask_ticket_id)'))
except Exception as exc:
print(f"[migrations] job_runs table not found; skipping migrate_job_runs_autotask_ticket_fields: {exc}")
return
print("[migrations] migrate_job_runs_autotask_ticket_fields completed.")
def migrate_jobs_archiving() -> None: def migrate_jobs_archiving() -> None:
"""Add archiving columns to jobs if missing. """Add archiving columns to jobs if missing.

View File

@ -127,7 +127,6 @@ class SystemSettings(db.Model):
autotask_cached_queues_json = db.Column(db.Text, nullable=True) autotask_cached_queues_json = db.Column(db.Text, nullable=True)
autotask_cached_ticket_sources_json = db.Column(db.Text, nullable=True) autotask_cached_ticket_sources_json = db.Column(db.Text, nullable=True)
autotask_cached_priorities_json = db.Column(db.Text, nullable=True) autotask_cached_priorities_json = db.Column(db.Text, nullable=True)
autotask_cached_ticket_statuses_json = db.Column(db.Text, nullable=True)
autotask_reference_last_sync_at = db.Column(db.DateTime, nullable=True) 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(
@ -276,12 +275,6 @@ class JobRun(db.Model):
reviewed_at = db.Column(db.DateTime, nullable=True) reviewed_at = db.Column(db.DateTime, nullable=True)
reviewed_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) reviewed_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
# Autotask integration (Phase 4: ticket creation from Run Checks)
autotask_ticket_id = db.Column(db.Integer, nullable=True)
autotask_ticket_number = db.Column(db.String(64), nullable=True)
autotask_ticket_created_at = db.Column(db.DateTime, nullable=True)
autotask_ticket_created_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column( updated_at = db.Column(
@ -295,8 +288,6 @@ class JobRun(db.Model):
reviewed_by = db.relationship("User", foreign_keys=[reviewed_by_user_id]) reviewed_by = db.relationship("User", foreign_keys=[reviewed_by_user_id])
autotask_ticket_created_by = db.relationship("User", foreign_keys=[autotask_ticket_created_by_user_id])
class JobRunReviewEvent(db.Model): class JobRunReviewEvent(db.Model):
__tablename__ = "job_run_review_events" __tablename__ = "job_run_review_events"

View File

@ -217,11 +217,13 @@
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="border rounded p-2"> <div class="border rounded p-2">
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
<div class="fw-semibold">Autotask ticket</div> <div class="fw-semibold">New ticket</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button> <button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button>
</div> </div>
<div class="mt-2 small" id="rcm_autotask_info"></div> <div class="mt-2">
<div class="mt-2 small text-muted" id="rcm_autotask_status"></div> <input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
</div>
<div class="mt-2 small text-muted" id="rcm_ticket_status"></div>
</div> </div>
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
@ -297,47 +299,9 @@
var currentRunId = null; var currentRunId = null;
var currentPayload = null; var currentPayload = null;
// Phase 2.1: Read-only Autotask ticket polling (Run Checks page only)
// Cache shape: { <ticketId>: {id, ticketNumber, status, statusName, title, lastActivityDate} }
var autotaskTicketPollCache = {};
function pollAutotaskTicketsOnPageOpen() {
// Only execute on Run Checks page load.
var url = '/api/run-checks/autotask-ticket-poll';
var qs = [];
// include_reviewed is only meaningful for admins
try {
var includeReviewed = {{ 'true' if include_reviewed else 'false' }};
if (includeReviewed) qs.push('include_reviewed=1');
} catch (e) {}
if (qs.length) url += '?' + qs.join('&');
fetch(url)
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j || j.status !== 'ok') return;
autotaskTicketPollCache = {};
var list = (j.tickets || []);
for (var i = 0; i < list.length; i++) {
var t = list[i] || {};
var id = parseInt(t.id, 10);
if (!Number.isFinite(id) || id <= 0) continue;
autotaskTicketPollCache[id] = t;
}
window.__rcAutotaskTicketPollCache = autotaskTicketPollCache;
})
.catch(function () {
autotaskTicketPollCache = {};
window.__rcAutotaskTicketPollCache = autotaskTicketPollCache;
});
}
var btnMarkAllReviewed = document.getElementById('rcm_mark_all_reviewed'); var btnMarkAllReviewed = document.getElementById('rcm_mark_all_reviewed');
var btnMarkSuccessOverride = document.getElementById('rcm_mark_success_override'); var btnMarkSuccessOverride = document.getElementById('rcm_mark_success_override');
pollAutotaskTicketsOnPageOpen();
// Shift-click range selection for checkbox rows // Shift-click range selection for checkbox rows
var lastCheckedCb = null; var lastCheckedCb = null;
@ -877,99 +841,56 @@ table.addEventListener('change', function (e) {
} }
function bindInlineCreateForms() { function bindInlineCreateForms() {
var btnAutotask = document.getElementById('rcm_autotask_create'); var btnTicket = document.getElementById('rcm_ticket_save');
var atInfo = document.getElementById('rcm_autotask_info');
var atStatus = document.getElementById('rcm_autotask_status');
var btnRemark = document.getElementById('rcm_remark_save'); var btnRemark = document.getElementById('rcm_remark_save');
var tCode = document.getElementById('rcm_ticket_code');
var tStatus = document.getElementById('rcm_ticket_status');
var rBody = document.getElementById('rcm_remark_body'); var rBody = document.getElementById('rcm_remark_body');
var rStatus = document.getElementById('rcm_remark_status'); var rStatus = document.getElementById('rcm_remark_status');
function clearStatus() { function clearStatus() {
if (atStatus) atStatus.textContent = ''; if (tStatus) tStatus.textContent = '';
if (rStatus) rStatus.textContent = ''; if (rStatus) rStatus.textContent = '';
} }
function setDisabled(disabled) { function setDisabled(disabled) {
if (btnAutotask) btnAutotask.disabled = disabled; if (btnTicket) btnTicket.disabled = disabled;
if (btnRemark) btnRemark.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled;
if (tCode) tCode.disabled = disabled;
if (rBody) rBody.disabled = disabled; if (rBody) rBody.disabled = disabled;
} }
window.__rcmSetCreateDisabled = setDisabled; window.__rcmSetCreateDisabled = setDisabled;
window.__rcmClearCreateStatus = clearStatus; window.__rcmClearCreateStatus = clearStatus;
function renderAutotaskInfo(run) { if (btnTicket) {
if (!atInfo) return; btnTicket.addEventListener('click', function () {
var num = (run && run.autotask_ticket_number) ? String(run.autotask_ticket_number) : '';
var tid = (run && run.autotask_ticket_id) ? parseInt(run.autotask_ticket_id, 10) : null;
var polled = (tid && autotaskTicketPollCache && autotaskTicketPollCache[tid]) ? autotaskTicketPollCache[tid] : null;
var lines = [];
if (num) {
lines.push('<div><strong>Ticket:</strong> ' + escapeHtml(num) + '</div>');
} else if (tid) {
lines.push('<div><strong>Ticket:</strong> created</div>');
} else {
lines.push('<div class="text-muted">No Autotask ticket created for this run.</div>');
}
// Phase 2.1 visibility only: show last polled status if available
if (tid) {
if (polled) {
var statusName = (polled.statusName || '').toString().trim();
var statusVal = (polled.status !== undefined && polled.status !== null) ? String(polled.status) : '';
var label = statusName ? statusName : (statusVal ? ('Status ' + statusVal) : '');
if (label) {
lines.push('<div class="text-muted">PSA status (polled): ' + escapeHtml(label) + '</div>');
}
} else {
lines.push('<div class="text-muted">PSA status (polled): not available</div>');
}
}
atInfo.innerHTML = lines.join('');
}
window.__rcmRenderAutotaskInfo = renderAutotaskInfo;
if (btnAutotask) {
btnAutotask.addEventListener('click', function () {
if (!currentRunId) { alert('Select a run first.'); return; } if (!currentRunId) { alert('Select a run first.'); return; }
clearStatus(); clearStatus();
if (atStatus) atStatus.textContent = 'Creating ticket...'; var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : '';
btnAutotask.disabled = true; if (!ticket_code) {
apiJson('/api/run-checks/autotask-ticket', { if (tStatus) tStatus.textContent = 'Ticket number is required.';
else alert('Ticket number is required.');
return;
}
if (!/^T\d{8}\.\d{4}$/.test(ticket_code)) {
if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.';
else alert('Invalid ticket number format. Expected TYYYYMMDD.####.');
return;
}
if (tStatus) tStatus.textContent = 'Saving...';
apiJson('/api/tickets', {
method: 'POST', method: 'POST',
body: JSON.stringify({run_id: currentRunId}) body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code})
}) })
.then(function (j) { .then(function () {
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.'); if (tCode) tCode.value = '';
if (atStatus) atStatus.textContent = ''; if (tStatus) tStatus.textContent = '';
loadAlerts(currentRunId);
// Refresh modal data so UI reflects stored ticket linkage.
var keepRunId = currentRunId;
if (currentJobId) {
return fetch('/api/run-checks/details?job_id=' + encodeURIComponent(currentJobId))
.then(function (r) { return r.json(); })
.then(function (payload) {
currentPayload = payload;
// Find the same run index
var idx = 0;
var runs = (payload && payload.runs) || [];
for (var i = 0; i < runs.length; i++) {
if (String(runs[i].id) === String(keepRunId)) { idx = i; break; }
}
// Re-render the currently open Run Checks modal with fresh data.
renderRun(payload, idx);
});
}
}) })
.catch(function (e) { .catch(function (e) {
if (atStatus) atStatus.textContent = e.message || 'Failed.'; if (tStatus) tStatus.textContent = e.message || 'Failed.';
else alert(e.message || 'Failed.'); else alert(e.message || 'Failed.');
})
.finally(function () {
// State will be recalculated by renderRun.
}); });
}); });
} }
@ -1035,8 +956,7 @@ table.addEventListener('change', function (e) {
currentRunId = run.id || null; currentRunId = run.id || null;
if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus(); if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus();
if (window.__rcmRenderAutotaskInfo) window.__rcmRenderAutotaskInfo(run); if (window.__rcmSetCreateDisabled) window.__rcmSetCreateDisabled(!currentRunId);
if (window.__rcmSetCreateDisabled) window.__rcmSetCreateDisabled(!currentRunId || !!run.autotask_ticket_id);
if (btnMarkSuccessOverride) { if (btnMarkSuccessOverride) {
var _rs = (run.status || '').toString().toLowerCase(); var _rs = (run.status || '').toString().toLowerCase();
var _canOverride = !!currentRunId && !run.missed && (_rs.indexOf('override') === -1) && (_rs.indexOf('success') === -1); var _canOverride = !!currentRunId && !run.missed && (_rs.indexOf('override') === -1) && (_rs.indexOf('success') === -1);
@ -1224,10 +1144,9 @@ table.addEventListener('change', function (e) {
var dot = run.missed ? "dot-missed" : statusDotClass(run.status); var dot = run.missed ? "dot-missed" : statusDotClass(run.status);
var dotHtml = dot ? ('<span class="status-dot ' + dot + ' me-2" aria-hidden="true"></span>') : ''; var dotHtml = dot ? ('<span class="status-dot ' + dot + ' me-2" aria-hidden="true"></span>') : '';
var reviewedMark = run.is_reviewed ? ' <span class="ms-2" title="Reviewed" aria-label="Reviewed"></span>' : ''; var reviewedMark = run.is_reviewed ? ' <span class="ms-2" title="Reviewed" aria-label="Reviewed"></span>' : '';
var ticketMark = run.autotask_ticket_id ? ' <span class="ms-2" title="Autotask ticket created" aria-label="Autotask ticket">🎫</span>' : '';
a.title = run.status || ''; a.title = run.status || '';
a.innerHTML = dotHtml + '<span class="text-nowrap">' + escapeHtml(run.run_at || 'Run') + '</span>' + reviewedMark + ticketMark; a.innerHTML = dotHtml + '<span class="text-nowrap">' + escapeHtml(run.run_at || 'Run') + '</span>' + reviewedMark;
a.addEventListener('click', function (ev) { a.addEventListener('click', function (ev) {
ev.preventDefault(); ev.preventDefault();
renderRun(data, idx); renderRun(data, idx);

View File

@ -397,17 +397,6 @@
<div class="form-text">Requires refreshed reference data.</div> <div class="form-text">Requires refreshed reference data.</div>
</div> </div>
<div class="col-md-6">
<label for="autotask_default_ticket_status" class="form-label">Default Ticket Status</label>
<select class="form-select" id="autotask_default_ticket_status" name="autotask_default_ticket_status">
<option value="" {% if not settings.autotask_default_ticket_status %}selected{% endif %}>Select...</option>
{% for st in autotask_ticket_statuses %}
<option value="{{ st.id }}" {% if settings.autotask_default_ticket_status == st.id %}selected{% endif %}>{{ st.name }}</option>
{% endfor %}
</select>
<div class="form-text">Required for Autotask ticket creation. Requires refreshed reference data.</div>
</div>
<div class="col-md-6"> <div class="col-md-6">
<label for="autotask_priority_warning" class="form-label">Priority for Warning</label> <label for="autotask_priority_warning" class="form-label">Priority for Warning</label>
<select class="form-select" id="autotask_priority_warning" name="autotask_priority_warning"> <select class="form-select" id="autotask_priority_warning" name="autotask_priority_warning">
@ -455,7 +444,6 @@
<div class="text-muted small mt-2"> <div class="text-muted small mt-2">
Cached Queues: {{ autotask_queues|length }}<br /> Cached Queues: {{ autotask_queues|length }}<br />
Cached Ticket Sources: {{ autotask_ticket_sources|length }}<br /> Cached Ticket Sources: {{ autotask_ticket_sources|length }}<br />
Cached Ticket Statuses: {{ autotask_ticket_statuses|length }}<br />
Cached Priorities: {{ autotask_priorities|length }} Cached Priorities: {{ autotask_priorities|length }}
</div> </div>
</div> </div>
@ -468,7 +456,7 @@
<button type="submit" class="btn btn-outline-primary">Refresh reference data</button> <button type="submit" class="btn btn-outline-primary">Refresh reference data</button>
</form> </form>
</div> </div>
<div class="form-text mt-2 text-md-end">Refresh loads Queues, Ticket Sources, Ticket Statuses, and Priorities from Autotask for dropdown usage.</div> <div 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>
</div> </div>