Auto-commit local changes before build (2026-01-15 14:08:59)
This commit is contained in:
parent
5b9b6f4c38
commit
c68b401709
@ -1 +1 @@
|
||||
v20260115-08-autotask-entityinfo-fields-shape-fix
|
||||
v20260115-09-autotask-customer-company-mapping
|
||||
|
||||
@ -14,7 +14,9 @@ class AutotaskZoneInfo:
|
||||
|
||||
|
||||
class AutotaskError(RuntimeError):
|
||||
pass
|
||||
def __init__(self, message: str, status_code: Optional[int] = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class AutotaskClient:
|
||||
@ -141,13 +143,18 @@ class AutotaskClient:
|
||||
"Authentication failed (HTTP 401). "
|
||||
"Verify API Username, API Secret, and ApiIntegrationCode. "
|
||||
f"Environment={self.environment}, ZoneInfoBase={zi_base}, ZoneApiUrl={zone.api_url}."
|
||||
,
|
||||
status_code=401,
|
||||
)
|
||||
if resp.status_code == 403:
|
||||
raise AutotaskError("Access forbidden (HTTP 403). API user permissions may be insufficient.")
|
||||
raise AutotaskError(
|
||||
"Access forbidden (HTTP 403). API user permissions may be insufficient.",
|
||||
status_code=403,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise AutotaskError(f"Resource not found (HTTP 404) for path: {path}")
|
||||
raise AutotaskError(f"Resource not found (HTTP 404) for path: {path}", status_code=404)
|
||||
if resp.status_code >= 400:
|
||||
raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).")
|
||||
raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).", status_code=resp.status_code)
|
||||
|
||||
try:
|
||||
return resp.json()
|
||||
@ -225,6 +232,58 @@ class AutotaskClient:
|
||||
"""
|
||||
return self._get_ticket_picklist_values(field_names=["source", "sourceid"])
|
||||
|
||||
def search_companies(self, query: str, limit: int = 25) -> List[Dict[str, Any]]:
|
||||
"""Search Companies by company name.
|
||||
|
||||
Uses the standard REST query endpoint:
|
||||
GET /Companies/query?search={...}
|
||||
|
||||
Returns a minimal list of dicts with keys: id, companyName, isActive.
|
||||
"""
|
||||
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
|
||||
# Keep payload small and predictable.
|
||||
# Field names in filters are case-insensitive in many tenants, but the docs
|
||||
# commonly show CompanyName.
|
||||
search_payload: Dict[str, Any] = {
|
||||
"filter": [
|
||||
{"op": "contains", "field": "CompanyName", "value": q},
|
||||
],
|
||||
"maxRecords": int(limit) if int(limit) > 0 else 25,
|
||||
}
|
||||
|
||||
params = {"search": json.dumps(search_payload)}
|
||||
data = self._request("GET", "Companies/query", params=params)
|
||||
items = self._as_items_list(data)
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
cid = it.get("id")
|
||||
name = it.get("companyName") or it.get("CompanyName") or ""
|
||||
try:
|
||||
cid_int = int(cid)
|
||||
except Exception:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"id": cid_int,
|
||||
"companyName": str(name),
|
||||
"isActive": bool(it.get("isActive", True)),
|
||||
}
|
||||
)
|
||||
|
||||
out.sort(key=lambda x: (x.get("companyName") or "").lower())
|
||||
return out
|
||||
|
||||
def get_company(self, company_id: int) -> Dict[str, Any]:
|
||||
"""Fetch a single Company by ID."""
|
||||
return self._request("GET", f"Companies/{int(company_id)}")
|
||||
|
||||
def _get_ticket_picklist_values(self, field_names: List[str]) -> List[Dict[str, Any]]:
|
||||
"""Retrieve picklist values for a Tickets field.
|
||||
|
||||
|
||||
@ -6,6 +6,14 @@ from .routes_shared import * # noqa: F401,F403
|
||||
def customers():
|
||||
items = Customer.query.order_by(Customer.name.asc()).all()
|
||||
|
||||
settings = _get_or_create_settings()
|
||||
autotask_enabled = bool(getattr(settings, "autotask_enabled", False))
|
||||
autotask_configured = bool(
|
||||
(getattr(settings, "autotask_api_username", None))
|
||||
and (getattr(settings, "autotask_api_password", None))
|
||||
and (getattr(settings, "autotask_tracking_identifier", None))
|
||||
)
|
||||
|
||||
rows = []
|
||||
for c in items:
|
||||
# Count jobs linked to this customer
|
||||
@ -19,6 +27,14 @@ def customers():
|
||||
"name": c.name,
|
||||
"active": bool(c.active),
|
||||
"job_count": job_count,
|
||||
"autotask_company_id": getattr(c, "autotask_company_id", None),
|
||||
"autotask_company_name": getattr(c, "autotask_company_name", None),
|
||||
"autotask_mapping_status": getattr(c, "autotask_mapping_status", None),
|
||||
"autotask_last_sync_at": (
|
||||
getattr(c, "autotask_last_sync_at", None).isoformat(timespec="seconds")
|
||||
if getattr(c, "autotask_last_sync_at", None)
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@ -28,9 +44,174 @@ def customers():
|
||||
"main/customers.html",
|
||||
customers=rows,
|
||||
can_manage=can_manage,
|
||||
autotask_enabled=autotask_enabled,
|
||||
autotask_configured=autotask_configured,
|
||||
)
|
||||
|
||||
|
||||
def _get_autotask_client_or_raise():
|
||||
"""Build an AutotaskClient from settings or raise a user-safe exception."""
|
||||
settings = _get_or_create_settings()
|
||||
if not bool(getattr(settings, "autotask_enabled", False)):
|
||||
raise RuntimeError("Autotask integration is disabled.")
|
||||
if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier:
|
||||
raise RuntimeError("Autotask settings incomplete.")
|
||||
|
||||
from ..integrations.autotask.client import AutotaskClient
|
||||
|
||||
return AutotaskClient(
|
||||
username=settings.autotask_api_username,
|
||||
password=settings.autotask_api_password,
|
||||
api_integration_code=settings.autotask_tracking_identifier,
|
||||
environment=(settings.autotask_environment or "production"),
|
||||
)
|
||||
|
||||
|
||||
@main_bp.get("/api/autotask/companies/search")
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
def api_autotask_companies_search():
|
||||
q = (request.args.get("q") or "").strip()
|
||||
if not q:
|
||||
return jsonify({"status": "ok", "items": []})
|
||||
|
||||
try:
|
||||
client = _get_autotask_client_or_raise()
|
||||
items = client.search_companies(q, limit=25)
|
||||
return jsonify({"status": "ok", "items": items})
|
||||
except Exception as exc:
|
||||
return jsonify({"status": "error", "message": str(exc) or "Search failed."}), 400
|
||||
|
||||
|
||||
def _normalize_company_name(company: dict) -> str:
|
||||
return str(company.get("companyName") or company.get("CompanyName") or company.get("name") or "").strip()
|
||||
|
||||
|
||||
@main_bp.get("/api/customers/<int:customer_id>/autotask-mapping")
|
||||
@login_required
|
||||
@roles_required("admin", "operator", "viewer")
|
||||
def api_customer_autotask_mapping_get(customer_id: int):
|
||||
c = Customer.query.get_or_404(customer_id)
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"customer": {
|
||||
"id": c.id,
|
||||
"autotask_company_id": getattr(c, "autotask_company_id", None),
|
||||
"autotask_company_name": getattr(c, "autotask_company_name", None),
|
||||
"autotask_mapping_status": getattr(c, "autotask_mapping_status", None),
|
||||
"autotask_last_sync_at": (
|
||||
getattr(c, "autotask_last_sync_at", None).isoformat(timespec="seconds")
|
||||
if getattr(c, "autotask_last_sync_at", None)
|
||||
else None
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping")
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
def api_customer_autotask_mapping_set(customer_id: int):
|
||||
c = Customer.query.get_or_404(customer_id)
|
||||
payload = request.get_json(silent=True) or {}
|
||||
company_id = payload.get("company_id")
|
||||
try:
|
||||
company_id_int = int(company_id)
|
||||
except Exception:
|
||||
return jsonify({"status": "error", "message": "Invalid company_id."}), 400
|
||||
|
||||
try:
|
||||
client = _get_autotask_client_or_raise()
|
||||
company = client.get_company(company_id_int)
|
||||
name = _normalize_company_name(company)
|
||||
|
||||
c.autotask_company_id = company_id_int
|
||||
c.autotask_company_name = name
|
||||
c.autotask_mapping_status = "ok"
|
||||
c.autotask_last_sync_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({"status": "ok"})
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
return jsonify({"status": "error", "message": str(exc) or "Failed to set mapping."}), 400
|
||||
|
||||
|
||||
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping/clear")
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
def api_customer_autotask_mapping_clear(customer_id: int):
|
||||
c = Customer.query.get_or_404(customer_id)
|
||||
try:
|
||||
c.autotask_company_id = None
|
||||
c.autotask_company_name = None
|
||||
c.autotask_mapping_status = None
|
||||
c.autotask_last_sync_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
return jsonify({"status": "ok"})
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
return jsonify({"status": "error", "message": str(exc) or "Failed to clear mapping."}), 400
|
||||
|
||||
|
||||
@main_bp.post("/api/customers/<int:customer_id>/autotask-mapping/refresh")
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
def api_customer_autotask_mapping_refresh(customer_id: int):
|
||||
from ..integrations.autotask.client import AutotaskError
|
||||
|
||||
c = Customer.query.get_or_404(customer_id)
|
||||
company_id = getattr(c, "autotask_company_id", None)
|
||||
if not company_id:
|
||||
return jsonify({"status": "ok", "mapping_status": None})
|
||||
|
||||
try:
|
||||
client = _get_autotask_client_or_raise()
|
||||
company = client.get_company(int(company_id))
|
||||
name = _normalize_company_name(company)
|
||||
|
||||
prev = (getattr(c, "autotask_company_name", None) or "").strip()
|
||||
if prev and name and prev != name:
|
||||
c.autotask_company_name = name
|
||||
c.autotask_mapping_status = "renamed"
|
||||
else:
|
||||
c.autotask_company_name = name
|
||||
c.autotask_mapping_status = "ok"
|
||||
c.autotask_last_sync_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({"status": "ok", "mapping_status": c.autotask_mapping_status, "company_name": c.autotask_company_name})
|
||||
except AutotaskError as exc:
|
||||
try:
|
||||
code = getattr(exc, "status_code", None)
|
||||
except Exception:
|
||||
code = None
|
||||
|
||||
# 404 -> deleted/missing company in Autotask
|
||||
if code == 404:
|
||||
try:
|
||||
c.autotask_mapping_status = "invalid"
|
||||
c.autotask_last_sync_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
return jsonify({"status": "ok", "mapping_status": "invalid"})
|
||||
|
||||
# Other errors: keep mapping but mark as missing (temporary/unreachable)
|
||||
try:
|
||||
c.autotask_mapping_status = "missing"
|
||||
c.autotask_last_sync_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
return jsonify({"status": "ok", "mapping_status": "missing", "message": str(exc)})
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
return jsonify({"status": "error", "message": str(exc) or "Refresh failed."}), 400
|
||||
|
||||
|
||||
@main_bp.route("/customers/create", methods=["POST"])
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
|
||||
@ -188,6 +188,41 @@ def migrate_system_settings_autotask_integration() -> None:
|
||||
print(f"[migrations] Failed to migrate system_settings autotask integration columns: {exc}")
|
||||
|
||||
|
||||
def migrate_customers_autotask_company_mapping() -> None:
|
||||
"""Add Autotask company mapping columns to customers if missing.
|
||||
|
||||
Columns:
|
||||
- autotask_company_id (INTEGER NULL)
|
||||
- autotask_company_name (VARCHAR(255) NULL)
|
||||
- autotask_mapping_status (VARCHAR(20) NULL)
|
||||
- autotask_last_sync_at (TIMESTAMP NULL)
|
||||
"""
|
||||
|
||||
table = "customers"
|
||||
columns = [
|
||||
("autotask_company_id", "INTEGER NULL"),
|
||||
("autotask_company_name", "VARCHAR(255) NULL"),
|
||||
("autotask_mapping_status", "VARCHAR(20) NULL"),
|
||||
("autotask_last_sync_at", "TIMESTAMP NULL"),
|
||||
]
|
||||
|
||||
try:
|
||||
engine = db.get_engine()
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Could not get engine for customers autotask mapping migration: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
for column, ddl in columns:
|
||||
if _column_exists_on_conn(conn, table, column):
|
||||
continue
|
||||
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN {column} {ddl}'))
|
||||
print("[migrations] migrate_customers_autotask_company_mapping completed.")
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Failed to migrate customers autotask company mapping columns: {exc}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -843,6 +878,7 @@ def run_migrations() -> None:
|
||||
migrate_system_settings_daily_jobs_start_date()
|
||||
migrate_system_settings_ui_timezone()
|
||||
migrate_system_settings_autotask_integration()
|
||||
migrate_customers_autotask_company_mapping()
|
||||
migrate_mail_messages_columns()
|
||||
migrate_mail_messages_parse_columns()
|
||||
migrate_mail_messages_approval_columns()
|
||||
|
||||
@ -153,6 +153,14 @@ class Customer(db.Model):
|
||||
name = db.Column(db.String(255), unique=True, nullable=False)
|
||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
# Autotask company mapping (Phase 3)
|
||||
# Company ID is leading; name is cached for UI display.
|
||||
autotask_company_id = db.Column(db.Integer, nullable=True)
|
||||
autotask_company_name = db.Column(db.String(255), nullable=True)
|
||||
# Mapping status: ok | renamed | missing | invalid
|
||||
autotask_mapping_status = db.Column(db.String(20), nullable=True)
|
||||
autotask_last_sync_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(
|
||||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
|
||||
@ -29,6 +29,8 @@
|
||||
<th scope="col">Customer</th>
|
||||
<th scope="col">Active</th>
|
||||
<th scope="col">Number of jobs</th>
|
||||
<th scope="col">Autotask company</th>
|
||||
<th scope="col">Autotask mapping</th>
|
||||
{% if can_manage %}
|
||||
<th scope="col">Actions</th>
|
||||
{% endif %}
|
||||
@ -46,6 +48,7 @@
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if c.job_count > 0 %}
|
||||
{{ c.job_count }}
|
||||
@ -53,6 +56,36 @@
|
||||
<span class="text-danger fw-bold">0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if c.autotask_company_id %}
|
||||
<span class="fw-semibold">{{ c.autotask_company_name or 'Unknown' }}</span>
|
||||
<div class="text-muted small">ID: {{ c.autotask_company_id }}</div>
|
||||
{% else %}
|
||||
<span class="text-muted">Not mapped</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% set st = (c.autotask_mapping_status or '').lower() %}
|
||||
{% if not c.autotask_company_id %}
|
||||
<span class="badge bg-secondary">Not mapped</span>
|
||||
{% elif st == 'ok' %}
|
||||
<span class="badge bg-success">OK</span>
|
||||
{% elif st == 'renamed' %}
|
||||
<span class="badge bg-warning text-dark">Renamed</span>
|
||||
{% elif st == 'missing' %}
|
||||
<span class="badge bg-warning text-dark">Missing</span>
|
||||
{% elif st == 'invalid' %}
|
||||
<span class="badge bg-danger">Invalid</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Unknown</span>
|
||||
{% endif %}
|
||||
|
||||
{% if c.autotask_last_sync_at %}
|
||||
<div class="text-muted small">Checked: {{ c.autotask_last_sync_at }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if can_manage %}
|
||||
<td>
|
||||
<button
|
||||
@ -63,6 +96,10 @@
|
||||
data-id="{{ c.id }}"
|
||||
data-name="{{ c.name }}"
|
||||
data-active="{{ '1' if c.active else '0' }}"
|
||||
data-autotask-company-id="{{ c.autotask_company_id or '' }}"
|
||||
data-autotask-company-name="{{ c.autotask_company_name or '' }}"
|
||||
data-autotask-mapping-status="{{ c.autotask_mapping_status or '' }}"
|
||||
data-autotask-last-sync-at="{{ c.autotask_last_sync_at or '' }}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@ -82,7 +119,7 @@
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="{% if can_manage %}4{% else %}3{% endif %}" class="text-center text-muted py-3">
|
||||
<td colspan="{% if can_manage %}6{% else %}5{% endif %}" class="text-center text-muted py-3">
|
||||
No customers found.
|
||||
</td>
|
||||
</tr>
|
||||
@ -130,6 +167,36 @@
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<h6 class="mb-2">Autotask mapping</h6>
|
||||
{% if autotask_enabled and autotask_configured %}
|
||||
<div class="mb-2">
|
||||
<div class="small text-muted">Current mapping</div>
|
||||
<div id="autotaskCurrentMapping" class="fw-semibold">Not mapped</div>
|
||||
<div id="autotaskCurrentMappingMeta" class="text-muted small"></div>
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control" id="autotaskCompanySearch" placeholder="Search Autotask companies" autocomplete="off" />
|
||||
<button class="btn btn-outline-secondary" type="button" id="autotaskCompanySearchBtn">Search</button>
|
||||
</div>
|
||||
|
||||
<div id="autotaskCompanyResults" class="border rounded p-2" style="max-height: 220px; overflow:auto;"></div>
|
||||
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="autotaskSetMappingBtn" disabled>Set mapping</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="autotaskRefreshMappingBtn">Refresh status</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="autotaskClearMappingBtn">Clear mapping</button>
|
||||
</div>
|
||||
|
||||
<div id="autotaskMappingMsg" class="small text-muted mt-2"></div>
|
||||
{% else %}
|
||||
<div class="text-muted small">
|
||||
Autotask integration is not available. Enable and configure it in Settings → Extensions & Integrations → Autotask.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
@ -152,6 +219,89 @@
|
||||
var nameInput = document.getElementById("edit_customer_name");
|
||||
var activeInput = document.getElementById("edit_customer_active");
|
||||
|
||||
// Autotask mapping UI (only present when integration is enabled/configured)
|
||||
var atCurrent = document.getElementById("autotaskCurrentMapping");
|
||||
var atCurrentMeta = document.getElementById("autotaskCurrentMappingMeta");
|
||||
var atSearchInput = document.getElementById("autotaskCompanySearch");
|
||||
var atSearchBtn = document.getElementById("autotaskCompanySearchBtn");
|
||||
var atResults = document.getElementById("autotaskCompanyResults");
|
||||
var atMsg = document.getElementById("autotaskMappingMsg");
|
||||
var atSetBtn = document.getElementById("autotaskSetMappingBtn");
|
||||
var atRefreshBtn = document.getElementById("autotaskRefreshMappingBtn");
|
||||
var atClearBtn = document.getElementById("autotaskClearMappingBtn");
|
||||
|
||||
var currentCustomerId = null;
|
||||
var selectedCompanyId = null;
|
||||
|
||||
function setMsg(text, isError) {
|
||||
if (!atMsg) {
|
||||
return;
|
||||
}
|
||||
atMsg.textContent = text || "";
|
||||
if (isError) {
|
||||
atMsg.classList.remove("text-muted");
|
||||
atMsg.classList.add("text-danger");
|
||||
} else {
|
||||
atMsg.classList.remove("text-danger");
|
||||
atMsg.classList.add("text-muted");
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentMapping(companyId, companyName, mappingStatus, lastSyncAt) {
|
||||
if (!atCurrent || !atCurrentMeta) {
|
||||
return;
|
||||
}
|
||||
if (!companyId) {
|
||||
atCurrent.textContent = "Not mapped";
|
||||
atCurrentMeta.textContent = "";
|
||||
return;
|
||||
}
|
||||
atCurrent.textContent = (companyName || "Unknown") + " (ID: " + companyId + ")";
|
||||
|
||||
var parts = [];
|
||||
if (mappingStatus) {
|
||||
parts.push("Status: " + mappingStatus);
|
||||
}
|
||||
if (lastSyncAt) {
|
||||
parts.push("Checked: " + lastSyncAt);
|
||||
}
|
||||
atCurrentMeta.textContent = parts.join(" • ");
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
if (!atResults) {
|
||||
return;
|
||||
}
|
||||
atResults.innerHTML = "<div class=\"text-muted small\">No results.</div>";
|
||||
}
|
||||
|
||||
function setSelectedCompanyId(cid) {
|
||||
selectedCompanyId = cid;
|
||||
if (atSetBtn) {
|
||||
atSetBtn.disabled = !selectedCompanyId;
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
var resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
var data = null;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
data = null;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
var msg = (data && data.message) ? data.message : ("Request failed (" + resp.status + ").");
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
var editButtons = document.querySelectorAll(".customer-edit-btn");
|
||||
editButtons.forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
@ -165,8 +315,140 @@
|
||||
if (id) {
|
||||
editForm.action = "{{ url_for('main.customers_edit', customer_id=0) }}".replace("0", id);
|
||||
}
|
||||
|
||||
// Autotask: seed current mapping from row data attributes
|
||||
currentCustomerId = id || null;
|
||||
if (atResults) {
|
||||
clearResults();
|
||||
}
|
||||
setSelectedCompanyId(null);
|
||||
setMsg("", false);
|
||||
|
||||
if (atCurrent) {
|
||||
var atCompanyId = btn.getAttribute("data-autotask-company-id") || "";
|
||||
var atCompanyName = btn.getAttribute("data-autotask-company-name") || "";
|
||||
var atStatus = btn.getAttribute("data-autotask-mapping-status") || "";
|
||||
var atLast = btn.getAttribute("data-autotask-last-sync-at") || "";
|
||||
renderCurrentMapping(atCompanyId, atCompanyName, atStatus, atLast);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (atSearchBtn && atSearchInput && atResults) {
|
||||
atSearchBtn.addEventListener("click", async function () {
|
||||
var q = (atSearchInput.value || "").trim();
|
||||
if (!q) {
|
||||
setMsg("Enter a search term.", true);
|
||||
return;
|
||||
}
|
||||
setMsg("Searching...", false);
|
||||
setSelectedCompanyId(null);
|
||||
atResults.innerHTML = "<div class=\"text-muted small\">Searching...</div>";
|
||||
|
||||
try {
|
||||
var resp = await fetch("/api/autotask/companies/search?q=" + encodeURIComponent(q), {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (!resp.ok || !data || data.status !== "ok") {
|
||||
throw new Error((data && data.message) ? data.message : "Search failed.");
|
||||
}
|
||||
var items = data.items || [];
|
||||
if (!items.length) {
|
||||
atResults.innerHTML = "<div class=\"text-muted small\">No companies found.</div>";
|
||||
setMsg("No companies found.", false);
|
||||
return;
|
||||
}
|
||||
var html = "";
|
||||
items.forEach(function (it) {
|
||||
var cid = it.id;
|
||||
var name = it.companyName || it.name || ("Company #" + cid);
|
||||
var active = (it.isActive === false) ? " (inactive)" : "";
|
||||
html +=
|
||||
"<div class=\"form-check\">" +
|
||||
"<input class=\"form-check-input\" type=\"radio\" name=\"autotaskCompanyPick\" id=\"at_company_" + cid + "\" value=\"" + cid + "\" />" +
|
||||
"<label class=\"form-check-label\" for=\"at_company_" + cid + "\">" +
|
||||
name.replace(/</g, "<").replace(/>/g, ">") +
|
||||
" <span class=\"text-muted\">(ID: " + cid + ")</span>" +
|
||||
"<span class=\"text-muted\">" + active + "</span>" +
|
||||
"</label>" +
|
||||
"</div>";
|
||||
});
|
||||
atResults.innerHTML = html;
|
||||
|
||||
var radios = atResults.querySelectorAll("input[name='autotaskCompanyPick']");
|
||||
radios.forEach(function (r) {
|
||||
r.addEventListener("change", function () {
|
||||
setSelectedCompanyId(r.value);
|
||||
setMsg("Selected company ID: " + r.value, false);
|
||||
});
|
||||
});
|
||||
|
||||
setMsg("Select a company and click Set mapping.", false);
|
||||
} catch (e) {
|
||||
atResults.innerHTML = "<div class=\"text-muted small\">No results.</div>";
|
||||
setMsg(e && e.message ? e.message : "Search failed.", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (atSetBtn) {
|
||||
atSetBtn.addEventListener("click", async function () {
|
||||
if (!currentCustomerId) {
|
||||
setMsg("No customer selected.", true);
|
||||
return;
|
||||
}
|
||||
if (!selectedCompanyId) {
|
||||
setMsg("Select a company first.", true);
|
||||
return;
|
||||
}
|
||||
atSetBtn.disabled = true;
|
||||
setMsg("Saving mapping...", false);
|
||||
try {
|
||||
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping", { company_id: selectedCompanyId });
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
setMsg(e && e.message ? e.message : "Failed to set mapping.", true);
|
||||
atSetBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (atRefreshBtn) {
|
||||
atRefreshBtn.addEventListener("click", async function () {
|
||||
if (!currentCustomerId) {
|
||||
setMsg("No customer selected.", true);
|
||||
return;
|
||||
}
|
||||
setMsg("Refreshing status...", false);
|
||||
try {
|
||||
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping/refresh", {});
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
setMsg(e && e.message ? e.message : "Refresh failed.", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (atClearBtn) {
|
||||
atClearBtn.addEventListener("click", async function () {
|
||||
if (!currentCustomerId) {
|
||||
setMsg("No customer selected.", true);
|
||||
return;
|
||||
}
|
||||
if (!confirm("Clear Autotask mapping for this customer?")) {
|
||||
return;
|
||||
}
|
||||
setMsg("Clearing mapping...", false);
|
||||
try {
|
||||
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping/clear", {});
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
setMsg(e && e.message ? e.message : "Clear failed.", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@ -73,6 +73,14 @@ Changes:
|
||||
- Resolved failures in queue, source, and priority picklist detection caused by empty or misparsed field metadata.
|
||||
- Stabilized Autotask connection testing across sandbox environments with differing metadata formats.
|
||||
|
||||
## v20260115-09-autotask-customer-company-mapping
|
||||
|
||||
- Added explicit Autotask company mapping to customers using ID-based linkage.
|
||||
- Extended customer data model with Autotask company ID, cached company name, mapping status, and last sync timestamp.
|
||||
- Implemented Autotask company search and lookup endpoints for customer mapping.
|
||||
- Added mapping status handling to detect renamed, missing, or invalid Autotask companies.
|
||||
- Updated Customers UI to allow searching, selecting, refreshing, and clearing Autotask company mappings.
|
||||
- Ensured mappings remain stable when Autotask company names change and block future ticket actions when mappings are invalid.
|
||||
|
||||
***
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user