Auto-commit local changes before build (2026-01-15 15:05:42)
This commit is contained in:
parent
49fd29a6f2
commit
3564bcf62f
@ -1 +1 @@
|
|||||||
v20260115-12-autotask-customers-refreshall-mappings
|
v20260115-13-autotask-runchecks-create-ticket
|
||||||
|
|||||||
@ -105,7 +105,13 @@ class AutotaskClient:
|
|||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
params: Optional[Dict[str, Any]] = None,
|
||||||
|
json_body: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Any:
|
||||||
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('/')}"
|
||||||
@ -120,6 +126,7 @@ 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,
|
||||||
)
|
)
|
||||||
@ -389,3 +396,30 @@ 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 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.")
|
||||||
|
|
||||||
|
data = self._request("POST", "Tickets", json_body=payload)
|
||||||
|
# Autotask commonly returns the created object or an items list.
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if "item" in data and isinstance(data.get("item"), dict):
|
||||||
|
return data["item"]
|
||||||
|
if "items" in data and isinstance(data.get("items"), list) and data.get("items"):
|
||||||
|
first = data.get("items")[0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
return first
|
||||||
|
if "id" in data:
|
||||||
|
return data
|
||||||
|
# Fallback: return normalized first item if possible
|
||||||
|
items = self._as_items_list(data)
|
||||||
|
if items:
|
||||||
|
return items[0]
|
||||||
|
|
||||||
|
raise AutotaskError("Autotask did not return a created ticket object.")
|
||||||
|
|||||||
@ -4,7 +4,8 @@ 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
|
from flask import jsonify, render_template, request, url_for
|
||||||
|
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
|
||||||
|
|
||||||
@ -35,6 +36,106 @@ from ..models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@ -753,6 +854,8 @@ 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 "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -770,6 +873,175 @@ 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.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: if already created, return existing linkage.
|
||||||
|
if getattr(run, "autotask_ticket_id", None):
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"ticket_id": int(run.autotask_ticket_id),
|
||||||
|
"ticket_number": getattr(run, "autotask_ticket_number", None) or "",
|
||||||
|
"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()
|
||||||
|
created = client.create_ticket(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400
|
||||||
|
|
||||||
|
ticket_id = created.get("id") if isinstance(created, dict) else None
|
||||||
|
ticket_number = None
|
||||||
|
if isinstance(created, dict):
|
||||||
|
ticket_number = created.get("ticketNumber") or created.get("number") or created.get("ticket_number")
|
||||||
|
|
||||||
|
if not ticket_id:
|
||||||
|
return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
run.autotask_ticket_id = int(ticket_id)
|
||||||
|
except Exception:
|
||||||
|
run.autotask_ticket_id = None
|
||||||
|
|
||||||
|
run.autotask_ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None
|
||||||
|
run.autotask_ticket_created_at = datetime.utcnow()
|
||||||
|
run.autotask_ticket_created_by_user_id = current_user.id
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.add(run)
|
||||||
|
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": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@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")
|
||||||
|
|||||||
@ -897,6 +897,7 @@ 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()
|
||||||
@ -904,6 +905,67 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@ -275,6 +275,12 @@ 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(
|
||||||
@ -288,6 +294,8 @@ 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"
|
||||||
|
|||||||
@ -214,18 +214,16 @@
|
|||||||
<div id="rcm_alerts" class="small"></div>
|
<div id="rcm_alerts" class="small"></div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div class="row g-2 align-items-start">
|
<div class="row g-2 align-items-start">
|
||||||
<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">New ticket</div>
|
<div class="fw-semibold">Autotask ticket</div>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button>
|
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2 small" id="rcm_autotask_info"></div>
|
||||||
<input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
|
<div class="mt-2 small text-muted" id="rcm_autotask_status"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 small text-muted" id="rcm_ticket_status"></div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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">
|
||||||
@ -841,56 +839,78 @@ table.addEventListener('change', function (e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindInlineCreateForms() {
|
function bindInlineCreateForms() {
|
||||||
var btnTicket = document.getElementById('rcm_ticket_save');
|
var btnAutotask = document.getElementById('rcm_autotask_create');
|
||||||
|
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 (tStatus) tStatus.textContent = '';
|
if (atStatus) atStatus.textContent = '';
|
||||||
if (rStatus) rStatus.textContent = '';
|
if (rStatus) rStatus.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDisabled(disabled) {
|
function setDisabled(disabled) {
|
||||||
if (btnTicket) btnTicket.disabled = disabled;
|
if (btnAutotask) btnAutotask.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;
|
||||||
|
|
||||||
if (btnTicket) {
|
function renderAutotaskInfo(run) {
|
||||||
btnTicket.addEventListener('click', function () {
|
if (!atInfo) return;
|
||||||
|
var num = (run && run.autotask_ticket_number) ? String(run.autotask_ticket_number) : '';
|
||||||
|
if (num) {
|
||||||
|
atInfo.innerHTML = '<div><strong>Ticket:</strong> ' + escapeHtml(num) + '</div>';
|
||||||
|
} else if (run && run.autotask_ticket_id) {
|
||||||
|
atInfo.innerHTML = '<div><strong>Ticket:</strong> created</div>';
|
||||||
|
} else {
|
||||||
|
atInfo.innerHTML = '<div class="text-muted">No Autotask ticket created for this run.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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();
|
||||||
var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : '';
|
if (atStatus) atStatus.textContent = 'Creating ticket...';
|
||||||
if (!ticket_code) {
|
btnAutotask.disabled = true;
|
||||||
if (tStatus) tStatus.textContent = 'Ticket number is required.';
|
apiJson('/api/run-checks/autotask-ticket', {
|
||||||
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({job_run_id: currentRunId, ticket_code: ticket_code})
|
body: JSON.stringify({run_id: currentRunId})
|
||||||
})
|
})
|
||||||
.then(function () {
|
.then(function (j) {
|
||||||
if (tCode) tCode.value = '';
|
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
|
||||||
if (tStatus) tStatus.textContent = '';
|
if (atStatus) atStatus.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; }
|
||||||
|
}
|
||||||
|
renderModal(payload, idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function (e) {
|
.catch(function (e) {
|
||||||
if (tStatus) tStatus.textContent = e.message || 'Failed.';
|
if (atStatus) atStatus.textContent = e.message || 'Failed.';
|
||||||
else alert(e.message || 'Failed.');
|
else alert(e.message || 'Failed.');
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
// State will be recalculated by renderModal/renderRun.
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -956,7 +976,8 @@ if (tStatus) tStatus.textContent = '';
|
|||||||
|
|
||||||
currentRunId = run.id || null;
|
currentRunId = run.id || null;
|
||||||
if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus();
|
if (window.__rcmClearCreateStatus) window.__rcmClearCreateStatus();
|
||||||
if (window.__rcmSetCreateDisabled) window.__rcmSetCreateDisabled(!currentRunId);
|
if (window.__rcmRenderAutotaskInfo) window.__rcmRenderAutotaskInfo(run);
|
||||||
|
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);
|
||||||
@ -1144,9 +1165,10 @@ if (tStatus) tStatus.textContent = '';
|
|||||||
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;
|
a.innerHTML = dotHtml + '<span class="text-nowrap">' + escapeHtml(run.run_at || 'Run') + '</span>' + reviewedMark + ticketMark;
|
||||||
a.addEventListener('click', function (ev) {
|
a.addEventListener('click', function (ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
renderRun(data, idx);
|
renderRun(data, idx);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user