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

This commit is contained in:
Ivo Oskamp 2026-01-19 11:20:52 +01:00
parent 82bdebb721
commit 36deb77806
8 changed files with 477 additions and 48 deletions

View File

@ -1 +1 @@
v20260119-01-restoredto-v20260115-12-autotask-customers-refreshall-mappings
v20260119-02-restoredto--v20260115-15-autotask-default-ticket-status-setting

View File

@ -105,7 +105,13 @@ class AutotaskClient:
"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()
base = zone.api_url.rstrip("/")
url = f"{base}/v1.0/{path.lstrip('/')}"
@ -120,6 +126,7 @@ class AutotaskClient:
url=url,
headers=h,
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,
timeout=self.timeout_seconds,
)
@ -389,3 +396,37 @@ class AutotaskClient:
raise AutotaskError("Tickets.priority metadata did not include 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 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.")

View File

@ -4,7 +4,8 @@ import calendar
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 sqlalchemy import and_, or_, func, text
@ -35,6 +36,106 @@ from ..models import (
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.
# A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot.
MISSED_GRACE_WINDOW = timedelta(hours=1)
@ -753,6 +854,8 @@ def run_checks_details():
"mail": mail_meta,
"body_html": body_html,
"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})
@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")
@login_required
@roles_required("admin", "operator")

View File

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

View File

@ -168,6 +168,7 @@ def migrate_system_settings_autotask_integration() -> None:
("autotask_cached_queues_json", "TEXT NULL"),
("autotask_cached_ticket_sources_json", "TEXT NULL"),
("autotask_cached_priorities_json", "TEXT NULL"),
("autotask_cached_ticket_statuses_json", "TEXT NULL"),
("autotask_reference_last_sync_at", "TIMESTAMP NULL"),
]
@ -897,6 +898,7 @@ def run_migrations() -> None:
migrate_overrides_match_columns()
migrate_job_runs_review_tracking()
migrate_job_runs_override_metadata()
migrate_job_runs_autotask_ticket_fields()
migrate_jobs_archiving()
migrate_news_tables()
migrate_reporting_tables()
@ -904,6 +906,67 @@ def run_migrations() -> None:
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:
"""Add archiving columns to jobs if missing.

View File

@ -127,6 +127,7 @@ class SystemSettings(db.Model):
autotask_cached_queues_json = db.Column(db.Text, nullable=True)
autotask_cached_ticket_sources_json = db.Column(db.Text, nullable=True)
autotask_cached_priorities_json = db.Column(db.Text, nullable=True)
autotask_cached_ticket_statuses_json = db.Column(db.Text, nullable=True)
autotask_reference_last_sync_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(
@ -275,6 +276,12 @@ class JobRun(db.Model):
reviewed_at = db.Column(db.DateTime, 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)
updated_at = db.Column(
@ -288,6 +295,8 @@ class JobRun(db.Model):
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):
__tablename__ = "job_run_review_events"

View File

@ -214,18 +214,16 @@
<div id="rcm_alerts" class="small"></div>
<div class="mt-2">
<div class="row g-2 align-items-start">
<div class="col-12 col-lg-6">
<div class="border rounded p-2">
<div class="d-flex align-items-center justify-content-between">
<div class="fw-semibold">New ticket</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button>
</div>
<div class="mt-2">
<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 class="col-12 col-lg-6">
<div class="border rounded p-2">
<div class="d-flex align-items-center justify-content-between">
<div class="fw-semibold">Autotask ticket</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button>
</div>
<div class="mt-2 small" id="rcm_autotask_info"></div>
<div class="mt-2 small text-muted" id="rcm_autotask_status"></div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="border rounded p-2">
<div class="d-flex align-items-center justify-content-between">
@ -841,56 +839,78 @@ table.addEventListener('change', function (e) {
}
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 tCode = document.getElementById('rcm_ticket_code');
var tStatus = document.getElementById('rcm_ticket_status');
var rBody = document.getElementById('rcm_remark_body');
var rStatus = document.getElementById('rcm_remark_status');
function clearStatus() {
if (tStatus) tStatus.textContent = '';
if (atStatus) atStatus.textContent = '';
if (rStatus) rStatus.textContent = '';
}
function setDisabled(disabled) {
if (btnTicket) btnTicket.disabled = disabled;
if (btnAutotask) btnAutotask.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.__rcmClearCreateStatus = clearStatus;
if (btnTicket) {
btnTicket.addEventListener('click', function () {
function renderAutotaskInfo(run) {
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; }
clearStatus();
var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : '';
if (!ticket_code) {
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', {
if (atStatus) atStatus.textContent = 'Creating ticket...';
btnAutotask.disabled = true;
apiJson('/api/run-checks/autotask-ticket', {
method: 'POST',
body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code})
body: JSON.stringify({run_id: currentRunId})
})
.then(function () {
if (tCode) tCode.value = '';
if (tStatus) tStatus.textContent = '';
loadAlerts(currentRunId);
.then(function (j) {
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
if (atStatus) atStatus.textContent = '';
// 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) {
if (tStatus) tStatus.textContent = e.message || 'Failed.';
if (atStatus) atStatus.textContent = 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;
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) {
var _rs = (run.status || '').toString().toLowerCase();
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 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 ticketMark = run.autotask_ticket_id ? ' <span class="ms-2" title="Autotask ticket created" aria-label="Autotask ticket">🎫</span>' : '';
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) {
ev.preventDefault();
renderRun(data, idx);

View File

@ -397,6 +397,17 @@
<div class="form-text">Requires refreshed reference data.</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">
<label for="autotask_priority_warning" class="form-label">Priority for Warning</label>
<select class="form-select" id="autotask_priority_warning" name="autotask_priority_warning">
@ -444,6 +455,7 @@
<div class="text-muted small mt-2">
Cached Queues: {{ autotask_queues|length }}<br />
Cached Ticket Sources: {{ autotask_ticket_sources|length }}<br />
Cached Ticket Statuses: {{ autotask_ticket_statuses|length }}<br />
Cached Priorities: {{ autotask_priorities|length }}
</div>
</div>
@ -456,7 +468,7 @@
<button type="submit" class="btn btn-outline-primary">Refresh reference data</button>
</form>
</div>
<div class="form-text mt-2 text-md-end">Refresh loads Queues, Ticket Sources, and Priorities from Autotask for dropdown usage.</div>
<div class="form-text mt-2 text-md-end">Refresh loads Queues, Ticket Sources, Ticket Statuses, and Priorities from Autotask for dropdown usage.</div>
</div>
</div>
</div>