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):
|
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:
|
class AutotaskClient:
|
||||||
@ -141,13 +143,18 @@ class AutotaskClient:
|
|||||||
"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,
|
||||||
)
|
)
|
||||||
if resp.status_code == 403:
|
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:
|
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:
|
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:
|
try:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
@ -225,6 +232,58 @@ class AutotaskClient:
|
|||||||
"""
|
"""
|
||||||
return self._get_ticket_picklist_values(field_names=["source", "sourceid"])
|
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]]:
|
def _get_ticket_picklist_values(self, field_names: List[str]) -> List[Dict[str, Any]]:
|
||||||
"""Retrieve picklist values for a Tickets field.
|
"""Retrieve picklist values for a Tickets field.
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,14 @@ from .routes_shared import * # noqa: F401,F403
|
|||||||
def customers():
|
def customers():
|
||||||
items = Customer.query.order_by(Customer.name.asc()).all()
|
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 = []
|
rows = []
|
||||||
for c in items:
|
for c in items:
|
||||||
# Count jobs linked to this customer
|
# Count jobs linked to this customer
|
||||||
@ -19,6 +27,14 @@ def customers():
|
|||||||
"name": c.name,
|
"name": c.name,
|
||||||
"active": bool(c.active),
|
"active": bool(c.active),
|
||||||
"job_count": job_count,
|
"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",
|
"main/customers.html",
|
||||||
customers=rows,
|
customers=rows,
|
||||||
can_manage=can_manage,
|
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"])
|
@main_bp.route("/customers/create", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator")
|
@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}")
|
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_daily_jobs_start_date()
|
||||||
migrate_system_settings_ui_timezone()
|
migrate_system_settings_ui_timezone()
|
||||||
migrate_system_settings_autotask_integration()
|
migrate_system_settings_autotask_integration()
|
||||||
|
migrate_customers_autotask_company_mapping()
|
||||||
migrate_mail_messages_columns()
|
migrate_mail_messages_columns()
|
||||||
migrate_mail_messages_parse_columns()
|
migrate_mail_messages_parse_columns()
|
||||||
migrate_mail_messages_approval_columns()
|
migrate_mail_messages_approval_columns()
|
||||||
|
|||||||
@ -153,6 +153,14 @@ class Customer(db.Model):
|
|||||||
name = db.Column(db.String(255), unique=True, nullable=False)
|
name = db.Column(db.String(255), unique=True, nullable=False)
|
||||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
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)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at = db.Column(
|
updated_at = db.Column(
|
||||||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||||
|
|||||||
@ -29,6 +29,8 @@
|
|||||||
<th scope="col">Customer</th>
|
<th scope="col">Customer</th>
|
||||||
<th scope="col">Active</th>
|
<th scope="col">Active</th>
|
||||||
<th scope="col">Number of jobs</th>
|
<th scope="col">Number of jobs</th>
|
||||||
|
<th scope="col">Autotask company</th>
|
||||||
|
<th scope="col">Autotask mapping</th>
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
<th scope="col">Actions</th>
|
<th scope="col">Actions</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -46,6 +48,7 @@
|
|||||||
<span class="badge bg-secondary">Inactive</span>
|
<span class="badge bg-secondary">Inactive</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
{% if c.job_count > 0 %}
|
{% if c.job_count > 0 %}
|
||||||
{{ c.job_count }}
|
{{ c.job_count }}
|
||||||
@ -53,6 +56,36 @@
|
|||||||
<span class="text-danger fw-bold">0</span>
|
<span class="text-danger fw-bold">0</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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 %}
|
{% if can_manage %}
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
@ -63,6 +96,10 @@
|
|||||||
data-id="{{ c.id }}"
|
data-id="{{ c.id }}"
|
||||||
data-name="{{ c.name }}"
|
data-name="{{ c.name }}"
|
||||||
data-active="{{ '1' if c.active else '0' }}"
|
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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@ -82,7 +119,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<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.
|
No customers found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -130,6 +167,36 @@
|
|||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<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 nameInput = document.getElementById("edit_customer_name");
|
||||||
var activeInput = document.getElementById("edit_customer_active");
|
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");
|
var editButtons = document.querySelectorAll(".customer-edit-btn");
|
||||||
editButtons.forEach(function (btn) {
|
editButtons.forEach(function (btn) {
|
||||||
btn.addEventListener("click", function () {
|
btn.addEventListener("click", function () {
|
||||||
@ -165,8 +315,140 @@
|
|||||||
if (id) {
|
if (id) {
|
||||||
editForm.action = "{{ url_for('main.customers_edit', customer_id=0) }}".replace("0", 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>
|
</script>
|
||||||
|
|||||||
@ -73,6 +73,14 @@ Changes:
|
|||||||
- Resolved failures in queue, source, and priority picklist detection caused by empty or misparsed field metadata.
|
- 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.
|
- 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