Compare commits
96 Commits
ae66457415
...
28f094f80b
| Author | SHA1 | Date | |
|---|---|---|---|
| 28f094f80b | |||
| 7693af9306 | |||
| 5ed4c41b80 | |||
| a910cc4abc | |||
| e12755321a | |||
| 240f8b5c90 | |||
| 02d7bdd5b8 | |||
| 753c14bb4e | |||
| ce245f7d49 | |||
| 34ac317607 | |||
| 3b087540cb | |||
| e5123952b2 | |||
| 4bbde92c8d | |||
| 7b3b89f50c | |||
| 52cd75e420 | |||
| 83d8d85f30 | |||
| 4b66ec1c6a | |||
| abf8b89d7c | |||
| 0ddeaf1896 | |||
| 89b9dd0264 | |||
| f91c081456 | |||
| 39bdd49fd0 | |||
| 5ec64e6a13 | |||
| 55c6f7ddd6 | |||
| 2667e44830 | |||
| 04f6041fe6 | |||
| 494f792c0d | |||
| bb804f9a1e | |||
| a4a6a60d45 | |||
| ddc6eaa12a | |||
| f6216b8803 | |||
| fb2651392c | |||
| e3303681e1 | |||
| 3c7f4c7926 | |||
| 3400af58d7 | |||
| 67fb063267 | |||
| ae1865dab3 | |||
| 92c67805e5 | |||
| fc0cf1ef96 | |||
| 899863a0de | |||
| e4e069a6b3 | |||
| dfca88d3bd | |||
| 5c0e1b08aa | |||
| 4b506986a6 | |||
| 5131d24751 | |||
| 63526be592 | |||
| b56cdacf6b | |||
| 4b3b6162a0 | |||
| a7a61fdd64 | |||
| 8407bf45ab | |||
| 0cabd2e0fc | |||
| 0c5dee307f | |||
| 0500491621 | |||
| 890553f23e | |||
| c5ff1e11a3 | |||
| c595c165ed | |||
| d272d12d24 | |||
| 2887a021ba | |||
| d5e3734b35 | |||
| 07e6630a89 | |||
| dabec03f91 | |||
| 36deb77806 | |||
| 82bdebb721 | |||
| f8a57efee0 | |||
| 46cc5b10ab | |||
| 4c18365753 | |||
| 4def0aad46 | |||
| 9025d70b8e | |||
| ef8d12065b | |||
| 25d1962f7b | |||
| 487f923064 | |||
| f780bbc399 | |||
| b46b7fbc21 | |||
| 9399082231 | |||
| 8a16ff010f | |||
| 748769afc0 | |||
| abb6780744 | |||
| 83a29a7a3c | |||
| 66f5a57fe0 | |||
| 473044bd67 | |||
| afd45cc568 | |||
| 3564bcf62f | |||
| 49fd29a6f2 | |||
| 49f6d41715 | |||
| 186807b098 | |||
| c68b401709 | |||
| 5b9b6f4c38 | |||
| 981d65c274 | |||
| 1a2ca59d16 | |||
| 83d487a206 | |||
| 490ab1ae34 | |||
| 1a64627a4e | |||
| d5fdc9a8d9 | |||
| f6310da575 | |||
| 48e7830957 | |||
| 777a9b4b31 |
@ -1 +1 @@
|
||||
v20260113-08-vspc-object-linking-normalize
|
||||
v20260205-13-changelog-python-structure
|
||||
|
||||
@ -69,6 +69,9 @@ def create_app():
|
||||
|
||||
This ensures that when a user opens the site for the first time each day,
|
||||
they land on the dashboard regardless of the bookmarked/deeplinked URL.
|
||||
|
||||
This behavior is controlled by the system setting `require_daily_dashboard_visit`.
|
||||
When disabled (the default), users can navigate directly to any page.
|
||||
"""
|
||||
|
||||
# Only for normal page loads.
|
||||
@ -99,6 +102,18 @@ def create_app():
|
||||
session["daily_dashboard_seen"] = _get_today_ui_date()
|
||||
return None
|
||||
|
||||
# Check if the feature is enabled in system settings.
|
||||
try:
|
||||
from .models import SystemSettings
|
||||
|
||||
settings = SystemSettings.query.first()
|
||||
if not settings or not getattr(settings, "require_daily_dashboard_visit", False):
|
||||
# Feature is disabled; skip redirect.
|
||||
return None
|
||||
except Exception:
|
||||
# On any error (e.g. column doesn't exist yet), skip redirect.
|
||||
return None
|
||||
|
||||
today = _get_today_ui_date()
|
||||
seen = (session.get("daily_dashboard_seen") or "").strip()
|
||||
if seen != today:
|
||||
|
||||
870
containers/backupchecks/src/backend/app/changelog.py
Normal file
870
containers/backupchecks/src/backend/app/changelog.py
Normal file
@ -0,0 +1,870 @@
|
||||
"""
|
||||
Changelog data structure for Backupchecks
|
||||
"""
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "v0.1.22",
|
||||
"date": "2026-02-05",
|
||||
"summary": "This major release introduces comprehensive Autotask PSA integration, enabling seamless ticket management, customer company mapping, and automated ticket lifecycle handling directly from Backupchecks. The integration includes extensive settings configuration, robust API client implementation, intelligent ticket linking across job runs, and conditional ticket status updates based on time entries.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Autotask Integration Core Features",
|
||||
"type": "feature",
|
||||
"subsections": [
|
||||
{
|
||||
"subtitle": "Settings and Configuration",
|
||||
"changes": [
|
||||
"Complete Autotask integration settings in Settings → Integrations",
|
||||
"Environment selection (Sandbox/Production) with automatic zone discovery",
|
||||
"API authentication with fallback support for different tenant configurations",
|
||||
"Tracking identifier (Integration Code) configuration for ticket attribution",
|
||||
"Connection testing and diagnostics",
|
||||
"Reference data synchronization (queues, sources, priorities, statuses)",
|
||||
"Configurable ticket defaults (queue, source, status, priority)",
|
||||
"Autotask integration and automatic mail import can now be properly disabled after being enabled (fixed unchecked checkbox processing)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"subtitle": "Customer Company Mapping",
|
||||
"changes": [
|
||||
"Explicit Autotask company mapping for customers using ID-based linkage",
|
||||
"Company search with auto-suggestions when opening unmapped customers",
|
||||
"Automatically populates search box with customer name and displays matching Autotask companies",
|
||||
"Mapping status tracking (ok/renamed/missing/invalid)",
|
||||
"Bulk mapping refresh for all customers",
|
||||
"Clear search boxes when opening modals for better user experience"
|
||||
]
|
||||
},
|
||||
{
|
||||
"subtitle": "Ticket Creation and Management",
|
||||
"changes": [
|
||||
"Create Autotask tickets directly from Run Checks page",
|
||||
"Automatic ticket number assignment and storage",
|
||||
"Link existing Autotask tickets to job runs",
|
||||
"Cross-company ticket search for overarching infrastructure issues (search by ticket number finds tickets across all companies)",
|
||||
"Ticket propagation to all active runs of the same job",
|
||||
"Internal ticket registration for legacy compatibility (Tickets, Tickets/Remarks, Job Details)",
|
||||
"Real-time ticket status polling and updates",
|
||||
"Deleted ticket detection and audit tracking (deletion date/time and deleted-by resource information)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"subtitle": "Ticket Resolution and Status Management",
|
||||
"changes": [
|
||||
"Conditional ticket status updates based on time entries:",
|
||||
" - Tickets without time entries: automatically closed (status 5 - Complete)",
|
||||
" - Tickets with time entries: remain open for time tracking continuation",
|
||||
"Dynamic confirmation messages indicating closure behavior based on time entry presence",
|
||||
"Safe resolution updates preserving stabilizing fields (issueType, subIssueType, source)",
|
||||
"Resolution field mirroring from internal ticket notes",
|
||||
"Ticket notes created via `/Tickets/{id}/Notes` endpoint with timezone-aware timestamps",
|
||||
"Deleted ticket handling with complete audit trail"
|
||||
]
|
||||
},
|
||||
{
|
||||
"subtitle": "Technical Implementation",
|
||||
"changes": [
|
||||
"Full-featured Autotask REST API client (`integrations/autotask/client.py`)",
|
||||
"Zone information discovery and endpoint resolution",
|
||||
"Robust authentication handling with header-based fallback for sandbox environments",
|
||||
"Picklist-based reference data retrieval (queues, sources, priorities, statuses)",
|
||||
"Entity metadata parsing with tenant-specific field detection",
|
||||
"Database migrations for Autotask linkage fields across SystemSettings, Customer, JobRun, and Ticket models",
|
||||
"Ticketing utilities for internal/external ticket synchronization",
|
||||
"Comprehensive API contract documentation (`docs/autotask_rest_api.md`)",
|
||||
"Functional design living document for integration architecture"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "User Interface Improvements",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Search boxes now clear automatically when opening modals (Run Checks Link existing, Customer mapping)",
|
||||
"Auto-search for similar company names when mapping unmapped customers",
|
||||
"Cross-company ticket search when using ticket numbers (e.g., \"T20260205.0001\")",
|
||||
"Dynamic confirmation messages for ticket resolution based on time entries",
|
||||
"Improved visibility of Autotask ticket information in Run Checks",
|
||||
"Status labels displayed instead of numeric codes in ticket lists",
|
||||
"\"Deleted in PSA\" status display with deletion audit information",
|
||||
"\"Resolved by PSA (Autotask)\" differentiation from Backupchecks-driven resolution"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Bug Fixes and Stability",
|
||||
"type": "fixed",
|
||||
"changes": [
|
||||
"Fixed Autotask REST API base URL casing (ATServicesRest/V1.0)",
|
||||
"Fixed reference data retrieval using correct picklist endpoints",
|
||||
"Fixed authentication fallback for sandbox-specific behavior",
|
||||
"Fixed company name display from nested API responses",
|
||||
"Fixed ticket ID normalization and response unwrapping (itemId handling)",
|
||||
"Fixed TicketJobRun linkage for legacy ticket behavior",
|
||||
"Fixed unchecked checkbox processing for enable/disable toggles (Autotask integration, automatic mail import)",
|
||||
"Fixed ticket resolution updates to preserve exact field values from GET response",
|
||||
"Fixed picklist field detection for tenant-specific metadata",
|
||||
"Fixed migration stability with idempotent column checks",
|
||||
"Fixed settings page crash with local helper functions",
|
||||
"Fixed Run Checks modal stacking and Bootstrap 4/5 compatibility",
|
||||
"Fixed JavaScript errors (renderModal → renderRun)",
|
||||
"Fixed indentation errors preventing application startup",
|
||||
"Fixed ticket propagation to ensure all active runs receive ticket linkage",
|
||||
"Fixed polling to use read-only operations without state mutation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Documentation",
|
||||
"type": "documentation",
|
||||
"changes": [
|
||||
"Added comprehensive Autotask REST API contract documentation (`docs/autotask_rest_api.md`)",
|
||||
"Created functional design living document for integration architecture",
|
||||
"Documented ticket lifecycle, status management, and time entry considerations",
|
||||
"Added changelog tracking for Claude Code changes (`docs/changelog-claude.md`)"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.21",
|
||||
"date": "2026-01-20",
|
||||
"summary": "This release focuses on improving correctness, consistency, and access control across core application workflows, with particular attention to changelog rendering, browser-specific mail readability, Run Checks visibility, role-based access restrictions, override flexibility, and VSPC object linking reliability. The goal is to ensure predictable behavior, clearer diagnostics, and safer administration across both day-to-day operations and complex multi-entity reports.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Changelog Rendering and Documentation Accuracy",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Updated the Changelog route to render remote Markdown content instead of plain text",
|
||||
"Enabled full Markdown parsing so headings, lists, links, and code blocks are displayed correctly",
|
||||
"Ensured the changelog always fetches the latest version directly from the source repository at request time",
|
||||
"Removed legacy plain-text rendering to prevent loss of structure and formatting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Mail Rendering and Browser Compatibility",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Forced a light color scheme for embedded mail content to prevent Microsoft Edge from applying automatic dark mode styling",
|
||||
"Added explicit `color-scheme` and `forced-color-adjust` rules so original mail CSS is respected",
|
||||
"Ensured consistent mail readability across Edge and Firefox",
|
||||
"Applied these fixes consistently across Inbox, Deleted Inbox, Job Details, Run Checks, Daily Jobs, and Admin All Mail views"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Run Checks Visibility and Consistency",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Added support for displaying the overall remark (overall_message) directly on the Run Checks page",
|
||||
"Ensured consistency between Run Checks and Job Details, where the overall remark was already available",
|
||||
"Improved operator visibility of high-level run context without requiring navigation to job details"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Initial Setup and User Existence Safeguards",
|
||||
"type": "fixed",
|
||||
"changes": [
|
||||
"Fixed an incorrect redirect to the \"Initial admin setup\" page when users already exist",
|
||||
"Changed setup detection logic from \"admin user exists\" to \"any user exists\"",
|
||||
"Ensured existing environments always show the login page instead of allowing a new initial admin to be created",
|
||||
"Prevented direct access to the initial setup route when at least one user is present"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Role-Based Access Control and Menu Restrictions",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Restricted the Reporter role to only access Dashboard, Reports, Changelog, and Feedback",
|
||||
"Updated menu rendering to fully hide unauthorized menu items for Reporter users",
|
||||
"Adjusted route access to ensure Feedback pages remain accessible for the Reporter role",
|
||||
"Improved overall consistency between visible navigation and backend access rules"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Override Matching Flexibility and Maintainability",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Added configurable error text matching modes for overrides: contains, exact, starts with, and ends with",
|
||||
"Updated override evaluation logic to apply the selected match mode across run remarks and object error messages",
|
||||
"Extended the overrides UI with a match type selector and improved edit support for existing overrides",
|
||||
"Added a database migration to create and backfill the `overrides.match_error_mode` field for existing records"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Job Deletion Stability",
|
||||
"type": "fixed",
|
||||
"changes": [
|
||||
"Fixed an error that occurred during job deletion",
|
||||
"Corrected backend deletion logic to prevent runtime exceptions",
|
||||
"Ensured related records are handled safely to avoid constraint or reference errors during removal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "VSPC Object Linking and Normalization",
|
||||
"type": "fixed",
|
||||
"changes": [
|
||||
"Fixed VSPC company name normalization so detection and object prefixing behave consistently",
|
||||
"Ensured filtered object persistence respects the UNIQUE(customer_id, object_name) constraint",
|
||||
"Correctly update `last_seen` timestamps for existing objects",
|
||||
"Added automatic object persistence routing for VSPC per-company runs, ensuring objects are linked to the correct customer and job",
|
||||
"Improved auto-approval for VSPC Active Alarms summaries with per-company run creation and case-insensitive object matching",
|
||||
"Added best-effort retroactive processing to automatically link older inbox messages once company mappings are approved"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "VSPC Normalization Bug Fixes and Backward Compatibility",
|
||||
"type": "fixed",
|
||||
"changes": [
|
||||
"Removed duplicate definitions of VSPC Active Alarms company extraction logic that caused inconsistent normalization",
|
||||
"Ensured a single, consistent normalization path is used when creating jobs and linking objects",
|
||||
"Improved object linking so real objects (e.g. HV01, USB Disk) are reliably associated with their jobs",
|
||||
"Restored automatic re-linking for both new and historical VSPC mails",
|
||||
"Added backward-compatible matching to prevent existing VSPC jobs from breaking due to earlier inconsistent company naming"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.20",
|
||||
"date": "2026-01-15",
|
||||
"summary": "This release delivers a comprehensive set of improvements focused on parser correctness, data consistency, and clearer operator workflows across Inbox handling, Run Checks, and administrative tooling. The main goal of these changes is to ensure that backup notifications are parsed reliably, presented consistently, and handled through predictable and auditable workflows, even for complex or multi-entity reports.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Mail Parsing and Data Integrity",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Fixed Veeam Backup for Microsoft 365 parsing where the overall summary message was not consistently stored",
|
||||
"Improved extraction of overall detail messages so permission and role warnings are reliably captured",
|
||||
"Ensured the extracted overall message is always available across Job Details, Run Checks, and reporting views",
|
||||
"Added decoding of HTML entities in parsed object fields (name, type, status, error message) before storage, ensuring characters such as ampersands are displayed correctly",
|
||||
"Improved robustness of parsing logic to prevent partial or misleading data from being stored when mails contain mixed or malformed content"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Object Classification and Sorting",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Updated object list sorting to improve readability and prioritization",
|
||||
"Objects are now grouped by severity in a fixed order: Errors first, then Warnings, followed by all other statuses",
|
||||
"Within each severity group, objects are sorted alphabetically (A–Z)",
|
||||
"Applied the same sorting logic consistently across Inbox, Job Details, Run Checks, Daily Jobs, and the Admin All Mail view",
|
||||
"Improved overall run status determination by reliably deriving the worst detected object state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Parsers Overview and Maintainability",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Refactored the Parsers overview page to use the central parser registry instead of a static, hardcoded list",
|
||||
"All available parsers are now displayed automatically, ensuring the page stays in sync as parsers are added or removed",
|
||||
"Removed hardcoded parser definitions from templates to improve long-term maintainability",
|
||||
"Fixed a startup crash in the parsers route caused by an invalid absolute import by switching to a package-relative import",
|
||||
"Prevented Gunicorn worker boot failures and Bad Gateway errors during application initialization"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "User Management and Feedback Handling",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Added support for editing user roles directly from the User Management interface",
|
||||
"Implemented backend logic to update existing role assignments without requiring user deletion",
|
||||
"Ensured role changes are applied immediately and reflected correctly in permissions and access control",
|
||||
"Updated feedback listings to show only Open items by default",
|
||||
"Ensured Resolved items are always sorted to the bottom when viewing all feedback",
|
||||
"Preserved existing filtering, searching, and user-controlled sorting behavior"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "UI Improvements and Usability Enhancements",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Introduced reusable ellipsis handling for long detail fields to prevent layout overlap",
|
||||
"Added click-to-expand behavior for truncated fields, with double-click support to expand and select all text",
|
||||
"Added automatic tooltips showing the full value when a field is truncated",
|
||||
"Removed the redundant \"Objects\" heading above objects tables to reduce visual clutter",
|
||||
"Applied truncation and expansion behavior consistently across Inbox, Deleted Mail, Run Checks, Daily Jobs, Job Detail views, and Admin All Mail",
|
||||
"Reset expanded ellipsis fields when Bootstrap modals or offcanvas components are opened or closed to prevent state leakage",
|
||||
"Fixed layout issues where the Objects table could overlap mail content in the Run Checks popup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Veeam Cloud Connect and VSPC Parsing",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Improved the Veeam Cloud Connect Report parser by combining User and Repository Name into a single object identifier",
|
||||
"Excluded \"TOTAL\" rows from object processing",
|
||||
"Correctly classified red rows as Errors and yellow/orange rows as Warnings",
|
||||
"Ensured overall status is set to Error when one or more objects are in error state",
|
||||
"Added support for Veeam Service Provider Console daily alarm summary emails",
|
||||
"Implemented per-company object aggregation and derived overall status from the worst detected state",
|
||||
"Improved detection of VSPC Active Alarms emails to prevent incorrect fallback to other Veeam parsers",
|
||||
"Fixed a SyntaxError in the VSPC parser that caused application startup failures"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "VSPC Company Mapping Workflow",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Introduced a dedicated company-mapping popup for VSPC Active Alarms summary reports",
|
||||
"Enabled manual mapping of companies found in mails to existing customers",
|
||||
"Implemented per-company job and run creation using the format \"Active alarms summary | <Company>\"",
|
||||
"Disabled the standard approval flow for this report type and replaced it with a dedicated mapping workflow",
|
||||
"Required all detected companies to be mapped before full approval, while still allowing partial approvals",
|
||||
"Prevented duplicate run creation on repeated approvals",
|
||||
"Improved visibility and usability of the mapping popup with scroll support for large company lists",
|
||||
"Ensured only alarms belonging to the selected company are attached to the corresponding run"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "NTFS Auditing and Synology ABB Enhancements",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Added full parser support for NTFS Auditing reports",
|
||||
"Improved hostname and FQDN extraction from subject lines, supporting multiple subject formats and prefixes",
|
||||
"Ensured consistent job name generation as \"<hostname> file audits\"",
|
||||
"Set overall status to Warning when detected change counts are greater than zero",
|
||||
"Improved Synology Active Backup for Business parsing to detect partially completed jobs as Warning",
|
||||
"Added support for localized completion messages and subject variants",
|
||||
"Improved per-device object extraction and ensured specific device statuses take precedence over generic listings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Workflow Simplification and Cleanup",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Removed the \"Mark success (override)\" button from the Run Checks popup",
|
||||
"Prevented creation of unintended overrides when marking individual runs as successful",
|
||||
"Simplified override handling so Run Checks actions no longer affect override administration",
|
||||
"Ensured firmware update notifications (QNAP) are treated as informational warnings and excluded from missing-run detection and reporting"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.19",
|
||||
"date": "2026-01-10",
|
||||
"summary": "This release delivers a broad set of improvements focused on reliability, transparency, and operational control across mail processing, administrative auditing, and Run Checks workflows.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Mail Import Reliability and Data Integrity",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Updated the mail import flow so messages are only moved to the processed folder after a successful database store and commit",
|
||||
"Prevented Graph emails from being moved when parsing, storing, or committing data fails",
|
||||
"Added explicit commit and rollback handling to guarantee database consistency",
|
||||
"Improved logging around import, commit, and rollback failures"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Administrative Mail Auditing and Visibility",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Introduced an admin-only \"All Mail\" audit page",
|
||||
"Implemented pagination with a fixed page size of 50 items",
|
||||
"Added always-visible search filters (From, Subject, Backup, Type, Job name, date range)",
|
||||
"Added \"Only unlinked\" filter to identify messages not associated with any job"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Run Checks Usability and Control",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Added copy-to-clipboard icon next to ticket numbers",
|
||||
"Introduced manual \"Success (override)\" action for Operators and Admins",
|
||||
"Updated UI indicators for overridden runs with blue success status",
|
||||
"Improved mail rendering with fallback to text bodies and EML extraction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Parser Enhancements",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Added parser support for 3CX SSL Certificate notification emails",
|
||||
"Added detection for Synology DSM automatic update cancellation messages"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.18",
|
||||
"date": "2026-01-05",
|
||||
"summary": "This release focuses on improving ticket reuse, scoping, and visibility across jobs, runs, and history views.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Ticket Linking and Reuse",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Updated ticket linking logic to allow the same ticket number across multiple jobs and runs",
|
||||
"Prevented duplicate ticket creation errors when reusing existing ticket codes",
|
||||
"Ensured existing tickets are consistently reused and linked"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Job History Enhancements",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Added Tickets and Remarks section to Job History mail popup",
|
||||
"Enabled viewing and managing tickets/remarks directly from Job History",
|
||||
"Aligned ticket handling with Run Checks popup behavior"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.17",
|
||||
"date": "2025-12-30",
|
||||
"summary": "This release focuses on improving job normalization, ticket and remark handling, UI usability, and the robustness of run and object detection.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Job Normalization and Aggregation",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Veeam job names now normalized to prevent duplicates (Combined/Full suffixes merged)",
|
||||
"Added support for archiving inactive jobs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Inbox and Bulk Operations",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Introduced multi-select inbox functionality for Operator and Admin roles",
|
||||
"Added bulk \"Delete selected\" action with validation and audit logging"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Tickets and Remarks",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Ticket creation now uses user-provided codes with strict validation",
|
||||
"Editing of tickets/remarks disabled; must be resolved and recreated",
|
||||
"Removed ticket description fields to prevent inconsistent data"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.16",
|
||||
"date": "2025-12-25",
|
||||
"summary": "This release significantly expands and stabilizes the reporting functionality, focusing on configurability, correctness, and richer output formats.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Reporting Enhancements",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Reports now job-aggregated instead of object-level",
|
||||
"Full report lifecycle management added",
|
||||
"Advanced reporting foundations with configurable definitions",
|
||||
"Multiple export formats: CSV, HTML, and PDF",
|
||||
"Extensive column selection with drag-and-drop ordering",
|
||||
"Job-level aggregated metrics and success rate charts"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.15",
|
||||
"date": "2025-12-20",
|
||||
"summary": "This release focused on improving operational clarity and usability by strengthening dashboard guidance and introducing reporting foundation.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Dashboard and User Guidance",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Added comprehensive explanatory section to Dashboard",
|
||||
"Implemented automatic redirection to Dashboard on first daily visit",
|
||||
"Refactored Settings area into clearly separated sections"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Dashboard News",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Added per-user Dashboard News section with read/unread tracking",
|
||||
"Full admin management of news items"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Run Checks Multi-Select",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Added Shift-click multi-selection for efficient bulk review",
|
||||
"Fixed edge cases with selection and checkbox synchronization"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.14",
|
||||
"date": "2025-12-15",
|
||||
"summary": "Focused on improving sorting, parsing, and override functionality.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Daily Jobs Sorting",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Consistent multi-level sort: Customer → Backup Software → Type → Job Name",
|
||||
"Fixed backend ordering to ensure server-side consistency"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Overrides Configuration",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Replaced free-text inputs with dropdowns for Backup Software and Type",
|
||||
"Made newly created overrides apply immediately and retroactively",
|
||||
"Added full support for editing existing overrides"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Overrides UI Indicators",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Introduced blue status indicator for runs with overrides applied",
|
||||
"Added persistent override reporting metadata to job runs"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.13",
|
||||
"date": "2025-12-10",
|
||||
"summary": "Focused on improving visibility and consistency of Tickets and Remarks.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Tickets and Remarks Visibility",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Added clear visual indicators for active Tickets and Remarks in Run Checks",
|
||||
"Enhanced Job Details to display actual ticket numbers and remark messages",
|
||||
"Improved navigation with direct \"Job page\" links"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Missed Run Detection",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Now includes ±1 hour tolerance window",
|
||||
"Respects configured UI timezone"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.12",
|
||||
"date": "2025-12-05",
|
||||
"summary": "Dashboard improvements, inbox soft-delete, and enhanced parser support.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Dashboard and UI",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Corrected dashboard counters for Expected, Missed, and Success (override) statuses",
|
||||
"Fixed layout issues and improved label wrapping",
|
||||
"Extended Job History with weekday labels and review metadata"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Inbox Soft-Delete",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Introduced soft-delete for Inbox messages",
|
||||
"Added Admin-only \"Deleted mails\" page with audit details",
|
||||
"Added popup previews for deleted mails"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Parser Enhancements",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Improved Veeam parsing (Health Check, License Key)",
|
||||
"Added Synology support (Active Backup, R-Sync, Account Protection)",
|
||||
"Added R-Drive Image and Syncovery parsers"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.11",
|
||||
"date": "2025-11-30",
|
||||
"summary": "Major stability fixes and introduction of Run Checks page.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Stability and Bug Fixes",
|
||||
"type": "fixed",
|
||||
"changes": [
|
||||
"Fixed multiple page crashes caused by missing imports",
|
||||
"Resolved Jinja2 template errors and SQL/runtime issues"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Run Checks Page",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Introduced new Run Checks page to review job runs independently",
|
||||
"Displays all unreviewed runs with no time-based filtering",
|
||||
"Supports bulk review actions and per-job review via popups",
|
||||
"Added admin-only features for audit and review management"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Timezone Support",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Added configurable timezone setting in Settings",
|
||||
"Updated all frontend date/time rendering to use configured timezone"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.10",
|
||||
"date": "2025-11-25",
|
||||
"summary": "Performance improvements and batch processing for large datasets.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Performance and Stability",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Reworked Re-parse all to process in controlled batches",
|
||||
"Added execution time guards to prevent timeouts",
|
||||
"Optimized job-matching queries and database operations"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Job Matching and Parsing",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Fixed approved job imports to persist from_address",
|
||||
"Improved Veeam Backup Job parsing with multi-line warnings/errors",
|
||||
"Fixed regressions in backup object detection and storage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Tickets and Overrides",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Introduced run-date scoped ticket activity",
|
||||
"Implemented scoping for remarks",
|
||||
"Improved override handling with immediate application"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.9",
|
||||
"date": "2025-11-20",
|
||||
"summary": "Changelog system improvements and code refactoring.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Changelog System",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Migrated to structured, non-markdown format",
|
||||
"Simplified rendering logic",
|
||||
"Standardized formatting across all versions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Code Refactoring",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Refactored large routes.py into multiple smaller modules",
|
||||
"Introduced shared routes module for common imports",
|
||||
"Fixed NameError issues after refactoring"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.8",
|
||||
"date": "2025-11-15",
|
||||
"summary": "Consistent job matching and auto-approval across all mail processing flows.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Job Matching Improvements",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Introduced single shared job-matching helper based on full unique key",
|
||||
"Updated manual inbox approval to reuse existing jobs",
|
||||
"Aligned inbox Re-parse all auto-approve logic",
|
||||
"Fixed automatic mail import auto-approve"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.7",
|
||||
"date": "2025-11-10",
|
||||
"summary": "Export/import functionality and parser enhancements.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Job Export and Import",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Introduced export and import functionality for approved jobs",
|
||||
"Import process automatically creates missing customers",
|
||||
"Updates existing jobs based on unique identity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Parser Enhancements",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Improved Boxafe parsing (Shared Drives, Domain Accounts)",
|
||||
"Added Synology Hyper Backup Dutch support",
|
||||
"Added Veeam SOBR and Health Check support"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.6",
|
||||
"date": "2025-11-05",
|
||||
"summary": "Auto-approve fixes and centralized changelog.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Bug Fixes",
|
||||
"type": "fixed",
|
||||
"changes": [
|
||||
"Corrected auto-approve logic for automatic mail imports",
|
||||
"Fixed Re-parse all to respect approved status",
|
||||
"Fixed multiple Jinja2 template syntax errors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Changelog Page",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Introduced centralized Changelog page",
|
||||
"Added to main navigation"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.5",
|
||||
"date": "2025-10-30",
|
||||
"summary": "Microsoft Graph restoration and application reset functionality.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Microsoft Graph",
|
||||
"type": "fixed",
|
||||
"changes": [
|
||||
"Restored Graph folder retrieval (fixed import error)",
|
||||
"Fixed automatic mail importer signal-based timeout issues",
|
||||
"Implemented missing backend logic for automatic imports"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Application Reset",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Added Application Reset option in Settings",
|
||||
"Full backend support for complete data wipe",
|
||||
"Confirmation step to prevent accidental resets"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.4",
|
||||
"date": "2025-10-25",
|
||||
"summary": "Database migration stability and object parsing improvements.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Database Stability",
|
||||
"type": "fixed",
|
||||
"changes": [
|
||||
"Stabilized migrations by running in separate transaction scopes",
|
||||
"Resolved backend startup 502 errors",
|
||||
"Eliminated ResourceClosedError exceptions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Object Parsing",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Aligned manual imports with Re-parse all behavior",
|
||||
"Ensured consistent object detection across all import paths",
|
||||
"Hardened against Microsoft Graph timeouts"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.3",
|
||||
"date": "2025-10-20",
|
||||
"summary": "Logging persistence and UI improvements.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Logging",
|
||||
"type": "fixed",
|
||||
"changes": [
|
||||
"Fixed logging persistence to database",
|
||||
"Added pagination (20 entries per page)",
|
||||
"Extended view to show all available log fields"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Jobs and Daily Jobs",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Standardized default sorting",
|
||||
"Persisted Daily Jobs start date setting",
|
||||
"Improved table readability and layout"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Tickets and Remarks",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Added database schema for persistent tickets",
|
||||
"Implemented Tickets page with tabbed navigation",
|
||||
"Added indicators in Daily Jobs for active tickets/remarks"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v0.1.2",
|
||||
"date": "2025-10-15",
|
||||
"summary": "Parser support expansion and in-app logging system.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Parser Support",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Extended Synology Hyper Backup parser (Strato HiDrive support)",
|
||||
"Improved handling of successful runs without objects"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Administration",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Introduced admin-only \"Delete all jobs\" action",
|
||||
"Ensured related mails moved back to Inbox on job deletion",
|
||||
"Fixed foreign key constraint issues"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Logging System",
|
||||
"type": "feature",
|
||||
"changes": [
|
||||
"Moved to in-app AdminLog-based logging",
|
||||
"Detailed logging per imported/auto-approved email",
|
||||
"Summary logging at end of import runs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Object Persistence",
|
||||
"type": "improvement",
|
||||
"changes": [
|
||||
"Restored persistence after manual approval",
|
||||
"Added maintenance action to backfill missing object links",
|
||||
"Centralized object persistence logic"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@ from .parsers import parse_mail_message
|
||||
from .parsers.veeam import extract_vspc_active_alarms_companies
|
||||
from .email_utils import normalize_from_address, extract_best_html_from_eml, is_effectively_blank_html
|
||||
from .job_matching import find_matching_job
|
||||
from .ticketing_utils import link_open_internal_tickets_to_run
|
||||
|
||||
|
||||
GRAPH_TOKEN_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||
@ -334,6 +335,12 @@ def _store_messages(settings: SystemSettings, messages):
|
||||
db.session.add(run)
|
||||
db.session.flush()
|
||||
|
||||
# Legacy behavior: link any open internal tickets (and propagate PSA linkage) to new runs.
|
||||
try:
|
||||
link_open_internal_tickets_to_run(run=run, job=job)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
auto_approved_runs.append((job.customer_id, job.id, run.id, mail.id))
|
||||
created_any = True
|
||||
|
||||
@ -384,6 +391,14 @@ def _store_messages(settings: SystemSettings, messages):
|
||||
db.session.add(run)
|
||||
db.session.flush() # ensure run.id is available
|
||||
|
||||
# Legacy behavior: link any open internal tickets (and propagate PSA linkage) to new runs.
|
||||
try:
|
||||
link_open_internal_tickets_to_run(run=run, job=job)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
# Update mail message to reflect approval
|
||||
mail.job_id = job.id
|
||||
if hasattr(mail, "approved"):
|
||||
|
||||
@ -347,6 +347,8 @@ def api_ticket_resolve(ticket_id: int):
|
||||
open_scope = TicketScope.query.filter_by(ticket_id=ticket.id, resolved_at=None).first()
|
||||
if open_scope is None and ticket.resolved_at is None:
|
||||
ticket.resolved_at = now
|
||||
if getattr(ticket, "resolved_origin", None) is None:
|
||||
ticket.resolved_origin = "backupchecks"
|
||||
|
||||
db.session.commit()
|
||||
except Exception as exc:
|
||||
@ -358,6 +360,8 @@ def api_ticket_resolve(ticket_id: int):
|
||||
# Global resolve (from central ticket list): resolve ticket and all scopes
|
||||
if ticket.resolved_at is None:
|
||||
ticket.resolved_at = now
|
||||
if getattr(ticket, "resolved_origin", None) is None:
|
||||
ticket.resolved_origin = "backupchecks"
|
||||
|
||||
try:
|
||||
# Resolve any still-open scopes
|
||||
|
||||
@ -1,48 +1,13 @@
|
||||
from .routes_shared import * # noqa: F401,F403
|
||||
|
||||
import markdown
|
||||
|
||||
|
||||
GITEA_CHANGELOG_RAW_URL = (
|
||||
"https://gitea.oskamp.info/ivooskamp/backupchecks/raw/branch/main/docs/changelog.md"
|
||||
)
|
||||
from ..changelog import CHANGELOG
|
||||
|
||||
|
||||
@main_bp.route("/changelog")
|
||||
@login_required
|
||||
@roles_required("admin", "operator", "reporter", "viewer")
|
||||
def changelog_page():
|
||||
changelog_md = ""
|
||||
changelog_html = ""
|
||||
error = None
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
GITEA_CHANGELOG_RAW_URL,
|
||||
timeout=10,
|
||||
headers={"Accept": "text/plain, text/markdown; q=0.9, */*; q=0.1"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"HTTP {resp.status_code}")
|
||||
changelog_md = resp.text or ""
|
||||
|
||||
changelog_html = markdown.markdown(
|
||||
changelog_md,
|
||||
extensions=[
|
||||
"fenced_code",
|
||||
"tables",
|
||||
"sane_lists",
|
||||
"toc",
|
||||
],
|
||||
output_format="html5",
|
||||
)
|
||||
except Exception as exc: # pragma: no cover
|
||||
error = f"Unable to load changelog from Gitea ({GITEA_CHANGELOG_RAW_URL}): {exc}"
|
||||
|
||||
return render_template(
|
||||
"main/changelog.html",
|
||||
changelog_md=changelog_md,
|
||||
changelog_html=changelog_html,
|
||||
changelog_error=error,
|
||||
changelog_source_url=GITEA_CHANGELOG_RAW_URL,
|
||||
changelog_versions=CHANGELOG,
|
||||
)
|
||||
|
||||
@ -1,11 +1,48 @@
|
||||
from .routes_shared import * # noqa: F401,F403
|
||||
|
||||
# Explicit imports for robustness across mixed deployments.
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import db
|
||||
from ..models import SystemSettings
|
||||
|
||||
|
||||
def _get_or_create_settings_local():
|
||||
"""Return SystemSettings, creating a default row if missing.
|
||||
|
||||
This module should not depend on star-imported helpers for settings.
|
||||
Mixed deployments (partial container updates) can otherwise raise a
|
||||
NameError on /customers when the shared helper is not present.
|
||||
"""
|
||||
|
||||
settings = SystemSettings.query.first()
|
||||
if settings is None:
|
||||
settings = SystemSettings(
|
||||
auto_import_enabled=False,
|
||||
auto_import_interval_minutes=15,
|
||||
auto_import_max_items=50,
|
||||
manual_import_batch_size=50,
|
||||
auto_import_cutoff_date=datetime.utcnow().date(),
|
||||
ingest_eml_retention_days=7,
|
||||
)
|
||||
db.session.add(settings)
|
||||
db.session.commit()
|
||||
return settings
|
||||
|
||||
@main_bp.route("/customers")
|
||||
@login_required
|
||||
@roles_required("admin", "operator", "viewer")
|
||||
def customers():
|
||||
items = Customer.query.order_by(Customer.name.asc()).all()
|
||||
|
||||
settings = _get_or_create_settings_local()
|
||||
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 +56,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 +73,259 @@ 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_local()
|
||||
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:
|
||||
# Autotask REST payload shapes vary between tenants/endpoints.
|
||||
# - Some single-entity GETs return {"item": {...}}
|
||||
# - Some may return {"items": [{...}]}
|
||||
if isinstance(company, dict):
|
||||
item = company.get("item")
|
||||
if isinstance(item, dict):
|
||||
company = item
|
||||
else:
|
||||
items = company.get("items")
|
||||
if isinstance(items, list) and items and isinstance(items[0], dict):
|
||||
company = items[0]
|
||||
|
||||
return str(
|
||||
(company or {}).get("companyName")
|
||||
or (company or {}).get("CompanyName")
|
||||
or (company or {}).get("name")
|
||||
or (company or {}).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.post("/api/customers/autotask-mapping/refresh-all")
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
def api_customers_autotask_mapping_refresh_all():
|
||||
"""Refresh mapping status for all customers that have an Autotask company ID."""
|
||||
|
||||
from ..integrations.autotask.client import AutotaskError
|
||||
|
||||
customers = Customer.query.filter(Customer.autotask_company_id.isnot(None)).all()
|
||||
if not customers:
|
||||
return jsonify({"status": "ok", "refreshed": 0, "counts": {"ok": 0, "renamed": 0, "missing": 0, "invalid": 0}})
|
||||
|
||||
try:
|
||||
client = _get_autotask_client_or_raise()
|
||||
except Exception as exc:
|
||||
return jsonify({"status": "error", "message": str(exc) or "Autotask is not configured."}), 400
|
||||
|
||||
counts = {"ok": 0, "renamed": 0, "missing": 0, "invalid": 0}
|
||||
refreshed = 0
|
||||
|
||||
for c in customers:
|
||||
company_id = getattr(c, "autotask_company_id", None)
|
||||
if not company_id:
|
||||
continue
|
||||
try:
|
||||
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"
|
||||
counts["renamed"] += 1
|
||||
else:
|
||||
c.autotask_company_name = name
|
||||
c.autotask_mapping_status = "ok"
|
||||
counts["ok"] += 1
|
||||
c.autotask_last_sync_at = datetime.utcnow()
|
||||
refreshed += 1
|
||||
except AutotaskError as exc:
|
||||
try:
|
||||
code = getattr(exc, "status_code", None)
|
||||
except Exception:
|
||||
code = None
|
||||
|
||||
if code == 404:
|
||||
c.autotask_mapping_status = "invalid"
|
||||
counts["invalid"] += 1
|
||||
else:
|
||||
c.autotask_mapping_status = "missing"
|
||||
counts["missing"] += 1
|
||||
c.autotask_last_sync_at = datetime.utcnow()
|
||||
refreshed += 1
|
||||
except Exception:
|
||||
c.autotask_mapping_status = "missing"
|
||||
c.autotask_last_sync_at = datetime.utcnow()
|
||||
counts["missing"] += 1
|
||||
refreshed += 1
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
return jsonify({"status": "ok", "refreshed": refreshed, "counts": counts})
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
return jsonify({"status": "error", "message": str(exc) or "Failed to refresh all mappings."}), 400
|
||||
|
||||
|
||||
@main_bp.route("/customers/create", methods=["POST"])
|
||||
@login_required
|
||||
@roles_required("admin", "operator")
|
||||
|
||||
@ -4,6 +4,7 @@ from .routes_shared import _format_datetime, _log_admin_event, _send_mail_messag
|
||||
from ..email_utils import extract_best_html_from_eml, is_effectively_blank_html
|
||||
from ..parsers.veeam import extract_vspc_active_alarms_companies
|
||||
from ..models import MailObject
|
||||
from ..ticketing_utils import link_open_internal_tickets_to_run
|
||||
|
||||
import time
|
||||
import re
|
||||
@ -294,6 +295,11 @@ def inbox_message_approve(message_id: int):
|
||||
if hasattr(run, 'storage_free_percent') and hasattr(msg, 'storage_free_percent'):
|
||||
run.storage_free_percent = msg.storage_free_percent
|
||||
db.session.add(run)
|
||||
db.session.flush()
|
||||
try:
|
||||
link_open_internal_tickets_to_run(run=run, job=job)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update mail message to reflect approval
|
||||
msg.job_id = job.id
|
||||
@ -523,6 +529,7 @@ def inbox_message_approve_vspc_companies(message_id: int):
|
||||
|
||||
# De-duplicate: do not create multiple runs for the same (mail_message_id, job_id).
|
||||
run = JobRun.query.filter(JobRun.job_id == job.id, JobRun.mail_message_id == msg.id).first()
|
||||
created = False
|
||||
if run:
|
||||
skipped_existing += 1
|
||||
else:
|
||||
@ -535,9 +542,17 @@ def inbox_message_approve_vspc_companies(message_id: int):
|
||||
)
|
||||
if hasattr(run, "remark"):
|
||||
run.remark = getattr(msg, "overall_message", None)
|
||||
|
||||
db.session.add(run)
|
||||
created = True
|
||||
|
||||
# Ensure we have IDs before linking tickets or persisting objects.
|
||||
db.session.flush()
|
||||
try:
|
||||
link_open_internal_tickets_to_run(run=run, job=job)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if created:
|
||||
created_runs.append(run)
|
||||
|
||||
# Persist objects for reporting (idempotent upsert; safe to repeat).
|
||||
@ -683,7 +698,12 @@ def inbox_message_approve_vspc_companies(message_id: int):
|
||||
if hasattr(run2, "remark"):
|
||||
run2.remark = getattr(other, "overall_message", None)
|
||||
db.session.add(run2)
|
||||
|
||||
db.session.flush()
|
||||
try:
|
||||
link_open_internal_tickets_to_run(run=run2, job=job2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist objects per company
|
||||
try:
|
||||
@ -1049,7 +1069,12 @@ def inbox_reparse_all():
|
||||
run.storage_free_percent = msg.storage_free_percent
|
||||
|
||||
db.session.add(run)
|
||||
|
||||
db.session.flush()
|
||||
try:
|
||||
link_open_internal_tickets_to_run(run=run, job=job)
|
||||
except Exception:
|
||||
pass
|
||||
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
|
||||
created_any = True
|
||||
|
||||
@ -1110,6 +1135,10 @@ def inbox_reparse_all():
|
||||
|
||||
db.session.add(run)
|
||||
db.session.flush() # ensure run.id is available
|
||||
try:
|
||||
link_open_internal_tickets_to_run(run=run, job=job)
|
||||
except Exception:
|
||||
pass
|
||||
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
|
||||
|
||||
msg.job_id = job.id
|
||||
@ -1209,6 +1238,10 @@ def inbox_reparse_all():
|
||||
|
||||
db.session.add(run)
|
||||
db.session.flush()
|
||||
try:
|
||||
link_open_internal_tickets_to_run(run=run, job=job)
|
||||
except Exception:
|
||||
pass
|
||||
auto_approved_runs.append((job.customer_id, job.id, run.id, msg.id))
|
||||
|
||||
msg.job_id = job.id
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,7 @@
|
||||
from .routes_shared import * # noqa: F401,F403
|
||||
from .routes_shared import _get_database_size_bytes, _get_or_create_settings, _format_bytes, _get_free_disk_bytes, _log_admin_event
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
@main_bp.route("/settings/jobs/delete-all", methods=["POST"])
|
||||
@login_required
|
||||
@ -405,6 +407,9 @@ def settings():
|
||||
section = (request.args.get("section") or "general").strip().lower() or "general"
|
||||
|
||||
if request.method == "POST":
|
||||
autotask_form_touched = any(str(k).startswith("autotask_") for k in (request.form or {}).keys())
|
||||
import_form_touched = any(str(k).startswith("auto_import_") or str(k).startswith("manual_import_") or str(k).startswith("ingest_eml_") for k in (request.form or {}).keys())
|
||||
|
||||
# NOTE: The Settings UI has multiple tabs with separate forms.
|
||||
# Only update values that are present in the submitted form, to avoid
|
||||
# clearing unrelated settings when saving from another tab.
|
||||
@ -430,6 +435,65 @@ def settings():
|
||||
if "ui_timezone" in request.form:
|
||||
settings.ui_timezone = (request.form.get("ui_timezone") or "").strip() or "Europe/Amsterdam"
|
||||
|
||||
# Navigation setting is in the same form (General tab), so process it here.
|
||||
# Checkbox: present in form = checked, absent = unchecked.
|
||||
settings.require_daily_dashboard_visit = bool(request.form.get("require_daily_dashboard_visit"))
|
||||
|
||||
# Autotask integration
|
||||
if "autotask_enabled" in request.form:
|
||||
settings.autotask_enabled = bool(request.form.get("autotask_enabled"))
|
||||
|
||||
if "autotask_environment" in request.form:
|
||||
env_val = (request.form.get("autotask_environment") or "").strip().lower()
|
||||
if env_val in ("sandbox", "production"):
|
||||
settings.autotask_environment = env_val
|
||||
else:
|
||||
settings.autotask_environment = None
|
||||
|
||||
if "autotask_api_username" in request.form:
|
||||
settings.autotask_api_username = (request.form.get("autotask_api_username") or "").strip() or None
|
||||
|
||||
if "autotask_api_password" in request.form:
|
||||
pw = (request.form.get("autotask_api_password") or "").strip()
|
||||
if pw:
|
||||
settings.autotask_api_password = pw
|
||||
|
||||
if "autotask_tracking_identifier" in request.form:
|
||||
settings.autotask_tracking_identifier = (request.form.get("autotask_tracking_identifier") or "").strip() or None
|
||||
|
||||
if "autotask_base_url" in request.form:
|
||||
settings.autotask_base_url = (request.form.get("autotask_base_url") or "").strip() or None
|
||||
|
||||
if "autotask_default_queue_id" in request.form:
|
||||
try:
|
||||
settings.autotask_default_queue_id = int(request.form.get("autotask_default_queue_id") or 0) or None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if "autotask_default_ticket_source_id" in request.form:
|
||||
try:
|
||||
settings.autotask_default_ticket_source_id = int(request.form.get("autotask_default_ticket_source_id") or 0) or None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if "autotask_default_ticket_status" in request.form:
|
||||
try:
|
||||
settings.autotask_default_ticket_status = int(request.form.get("autotask_default_ticket_status") or 0) or None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if "autotask_priority_warning" in request.form:
|
||||
try:
|
||||
settings.autotask_priority_warning = int(request.form.get("autotask_priority_warning") or 0) or None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if "autotask_priority_error" in request.form:
|
||||
try:
|
||||
settings.autotask_priority_error = int(request.form.get("autotask_priority_error") or 0) or None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Daily Jobs
|
||||
if "daily_jobs_start_date" in request.form:
|
||||
daily_jobs_start_date_str = (request.form.get("daily_jobs_start_date") or "").strip()
|
||||
@ -442,7 +506,9 @@ def settings():
|
||||
settings.daily_jobs_start_date = None
|
||||
|
||||
# Import configuration
|
||||
if "auto_import_enabled" in request.form:
|
||||
# Checkbox: only update when any import field is present (form was submitted)
|
||||
# Unchecked checkboxes are not sent by browsers, so check import_form_touched
|
||||
if import_form_touched:
|
||||
settings.auto_import_enabled = bool(request.form.get("auto_import_enabled"))
|
||||
|
||||
if "auto_import_interval_minutes" in request.form:
|
||||
@ -506,6 +572,48 @@ def settings():
|
||||
db.session.commit()
|
||||
flash("Settings have been saved.", "success")
|
||||
|
||||
# Autotask ticket defaults depend on reference data (queues, sources, statuses, priorities).
|
||||
# When the Autotask integration is (re)configured, auto-refresh the cached reference data
|
||||
# once so the dropdowns become usable immediately.
|
||||
try:
|
||||
if (
|
||||
autotask_form_touched
|
||||
and bool(getattr(settings, "autotask_enabled", False))
|
||||
and bool(getattr(settings, "autotask_api_username", None))
|
||||
and bool(getattr(settings, "autotask_api_password", None))
|
||||
and bool(getattr(settings, "autotask_tracking_identifier", None))
|
||||
):
|
||||
missing_cache = (
|
||||
not bool(getattr(settings, "autotask_cached_queues_json", None))
|
||||
or not bool(getattr(settings, "autotask_cached_ticket_sources_json", None))
|
||||
or not bool(getattr(settings, "autotask_cached_ticket_statuses_json", None))
|
||||
or not bool(getattr(settings, "autotask_cached_priorities_json", None))
|
||||
)
|
||||
|
||||
if missing_cache:
|
||||
queues, sources, statuses, pr_out = _refresh_autotask_reference_data(settings)
|
||||
db.session.commit()
|
||||
flash(
|
||||
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_reference_data_auto_refreshed",
|
||||
"Autotask reference data auto-refreshed after settings save.",
|
||||
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:
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
flash(f"Autotask reference data refresh failed: {exc}", "warning")
|
||||
_log_admin_event(
|
||||
"autotask_reference_data_auto_refresh_failed",
|
||||
"Autotask reference data auto-refresh failed after settings save.",
|
||||
details=json.dumps({"error": str(exc)}),
|
||||
)
|
||||
|
||||
# If EML storage has been turned off, clear any stored blobs immediately.
|
||||
try:
|
||||
if getattr(settings, "ingest_eml_retention_days", 7) == 0:
|
||||
@ -537,6 +645,7 @@ def settings():
|
||||
free_disk_warning = free_disk_bytes < two_gb
|
||||
|
||||
has_client_secret = bool(settings.graph_client_secret)
|
||||
has_autotask_password = bool(getattr(settings, "autotask_api_password", None))
|
||||
|
||||
# Common UI timezones (IANA names)
|
||||
tz_options = [
|
||||
@ -595,6 +704,37 @@ def settings():
|
||||
except Exception:
|
||||
admin_users_count = 0
|
||||
|
||||
# Autotask cached reference data for dropdowns
|
||||
autotask_queues = []
|
||||
autotask_ticket_sources = []
|
||||
autotask_priorities = []
|
||||
autotask_ticket_statuses = []
|
||||
autotask_last_sync_at = getattr(settings, "autotask_reference_last_sync_at", None)
|
||||
|
||||
try:
|
||||
if getattr(settings, "autotask_cached_queues_json", None):
|
||||
autotask_queues = json.loads(settings.autotask_cached_queues_json) or []
|
||||
except Exception:
|
||||
autotask_queues = []
|
||||
|
||||
try:
|
||||
if getattr(settings, "autotask_cached_ticket_sources_json", None):
|
||||
autotask_ticket_sources = json.loads(settings.autotask_cached_ticket_sources_json) or []
|
||||
except Exception:
|
||||
autotask_ticket_sources = []
|
||||
|
||||
try:
|
||||
if getattr(settings, "autotask_cached_priorities_json", None):
|
||||
autotask_priorities = json.loads(settings.autotask_cached_priorities_json) or []
|
||||
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,
|
||||
@ -602,10 +742,16 @@ def settings():
|
||||
free_disk_human=free_disk_human,
|
||||
free_disk_warning=free_disk_warning,
|
||||
has_client_secret=has_client_secret,
|
||||
has_autotask_password=has_autotask_password,
|
||||
tz_options=tz_options,
|
||||
users=users,
|
||||
admin_users_count=admin_users_count,
|
||||
section=section,
|
||||
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,
|
||||
)
|
||||
@ -1172,3 +1318,147 @@ def settings_folders():
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"status": "error", "message": str(exc) or "Failed to load folders."}), 500
|
||||
|
||||
|
||||
@main_bp.route("/settings/autotask/test-connection", methods=["POST"])
|
||||
@login_required
|
||||
@roles_required("admin")
|
||||
def settings_autotask_test_connection():
|
||||
settings = _get_or_create_settings()
|
||||
|
||||
if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier:
|
||||
flash("Autotask settings incomplete. Provide username, password and tracking identifier first.", "warning")
|
||||
return redirect(url_for("main.settings", section="integrations"))
|
||||
|
||||
try:
|
||||
from ..integrations.autotask.client import AutotaskClient
|
||||
client = AutotaskClient(
|
||||
username=settings.autotask_api_username,
|
||||
password=settings.autotask_api_password,
|
||||
api_integration_code=settings.autotask_tracking_identifier,
|
||||
environment=(settings.autotask_environment or "production"),
|
||||
)
|
||||
zone = client.get_zone_info()
|
||||
# Lightweight authenticated calls to validate credentials and basic API access
|
||||
_ = client.get_queues()
|
||||
_ = client.get_ticket_sources()
|
||||
flash(f"Autotask connection OK. Zone: {zone.zone_name or 'unknown'}.", "success")
|
||||
_log_admin_event(
|
||||
"autotask_test_connection",
|
||||
"Autotask test connection succeeded.",
|
||||
details=json.dumps({"zone": zone.zone_name, "api_url": zone.api_url}),
|
||||
)
|
||||
except Exception as exc:
|
||||
flash(f"Autotask connection failed: {exc}", "danger")
|
||||
_log_admin_event(
|
||||
"autotask_test_connection_failed",
|
||||
"Autotask test connection failed.",
|
||||
details=json.dumps({"error": str(exc)}),
|
||||
)
|
||||
|
||||
return redirect(url_for("main.settings", section="integrations"))
|
||||
|
||||
|
||||
def _refresh_autotask_reference_data(settings):
|
||||
"""Refresh and persist Autotask reference data used for ticket default dropdowns."""
|
||||
from ..integrations.autotask.client import AutotaskClient
|
||||
|
||||
client = AutotaskClient(
|
||||
username=settings.autotask_api_username,
|
||||
password=settings.autotask_api_password,
|
||||
api_integration_code=settings.autotask_tracking_identifier,
|
||||
environment=(settings.autotask_environment or "production"),
|
||||
)
|
||||
|
||||
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)
|
||||
# instead of entity collections (id/name). We normalize both shapes.
|
||||
def _norm(items):
|
||||
out = []
|
||||
for it in items or []:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
_id = it.get("id")
|
||||
if _id is None:
|
||||
_id = it.get("value")
|
||||
|
||||
name = (
|
||||
it.get("name")
|
||||
or it.get("label")
|
||||
or it.get("queueName")
|
||||
or it.get("sourceName")
|
||||
or it.get("description")
|
||||
or ""
|
||||
)
|
||||
try:
|
||||
_id_int = int(_id)
|
||||
except Exception:
|
||||
continue
|
||||
out.append({"id": _id_int, "name": str(name)})
|
||||
# Sort by name for stable dropdowns
|
||||
out.sort(key=lambda x: (x.get("name") or "").lower())
|
||||
return out
|
||||
|
||||
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 = []
|
||||
for it in priorities or []:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
if it.get("isActive") is False:
|
||||
continue
|
||||
val = it.get("value")
|
||||
label = it.get("label") or it.get("name") or ""
|
||||
try:
|
||||
val_int = int(val)
|
||||
except Exception:
|
||||
continue
|
||||
pr_out.append({"id": val_int, "name": str(label)})
|
||||
pr_out.sort(key=lambda x: (x.get("name") or "").lower())
|
||||
|
||||
settings.autotask_cached_priorities_json = json.dumps(pr_out)
|
||||
settings.autotask_reference_last_sync_at = datetime.utcnow()
|
||||
|
||||
return queues, sources, statuses, pr_out
|
||||
|
||||
|
||||
@main_bp.route("/settings/autotask/refresh-reference-data", methods=["POST"])
|
||||
@login_required
|
||||
@roles_required("admin")
|
||||
def settings_autotask_refresh_reference_data():
|
||||
settings = _get_or_create_settings()
|
||||
|
||||
if not settings.autotask_api_username or not settings.autotask_api_password or not settings.autotask_tracking_identifier:
|
||||
flash("Autotask settings incomplete. Provide username, password and tracking identifier first.", "warning")
|
||||
return redirect(url_for("main.settings", section="integrations"))
|
||||
|
||||
try:
|
||||
queues, sources, statuses, pr_out = _refresh_autotask_reference_data(settings)
|
||||
db.session.commit()
|
||||
|
||||
flash(
|
||||
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 []), "ticket_statuses": len(statuses or []), "priorities": len(pr_out)}),
|
||||
)
|
||||
except Exception as exc:
|
||||
flash(f"Failed to refresh Autotask reference data: {exc}", "danger")
|
||||
_log_admin_event(
|
||||
"autotask_refresh_reference_data_failed",
|
||||
"Autotask reference data refresh failed.",
|
||||
details=json.dumps({"error": str(exc)}),
|
||||
)
|
||||
|
||||
return redirect(url_for("main.settings", section="integrations"))
|
||||
|
||||
@ -22,6 +22,51 @@ def _is_column_nullable(table_name: str, column_name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _column_exists_on_conn(conn, table_name: str, column_name: str) -> bool:
|
||||
"""Return True if the given column exists using the provided connection.
|
||||
|
||||
This helper is useful inside engine.begin() blocks so we can check
|
||||
column existence without creating a new inspector/connection.
|
||||
"""
|
||||
result = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = :table
|
||||
AND column_name = :column
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"table": table_name, "column": column_name},
|
||||
)
|
||||
return result.first() is not None
|
||||
|
||||
|
||||
def _get_table_columns(conn, table_name: str) -> set[str]:
|
||||
"""Return a set of column names for the given table using the provided connection.
|
||||
|
||||
This helper is designed for use inside engine.begin() blocks so that any
|
||||
errors are properly rolled back before the connection is returned to the pool.
|
||||
|
||||
If the table does not exist (or cannot be inspected), an empty set is returned.
|
||||
"""
|
||||
try:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = :table
|
||||
"""
|
||||
),
|
||||
{"table": table_name},
|
||||
)
|
||||
return {row[0] for row in result.fetchall()}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def migrate_add_username_to_users() -> None:
|
||||
"""Ensure users.username column exists and is NOT NULL and UNIQUE.
|
||||
|
||||
@ -128,6 +173,215 @@ def migrate_system_settings_ui_timezone() -> None:
|
||||
print(f"[migrations] Failed to migrate system_settings.ui_timezone: {exc}")
|
||||
|
||||
|
||||
def migrate_system_settings_autotask_integration() -> None:
|
||||
"""Add Autotask integration columns to system_settings if missing."""
|
||||
|
||||
table = "system_settings"
|
||||
|
||||
columns = [
|
||||
("autotask_enabled", "BOOLEAN NOT NULL DEFAULT FALSE"),
|
||||
("autotask_environment", "VARCHAR(32) NULL"),
|
||||
("autotask_api_username", "VARCHAR(255) NULL"),
|
||||
("autotask_api_password", "VARCHAR(255) NULL"),
|
||||
("autotask_tracking_identifier", "VARCHAR(255) NULL"),
|
||||
("autotask_base_url", "VARCHAR(512) NULL"),
|
||||
("autotask_default_queue_id", "INTEGER NULL"),
|
||||
("autotask_default_ticket_source_id", "INTEGER NULL"),
|
||||
("autotask_default_ticket_status", "INTEGER NULL"),
|
||||
("autotask_priority_warning", "INTEGER NULL"),
|
||||
("autotask_priority_error", "INTEGER NULL"),
|
||||
("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"),
|
||||
]
|
||||
|
||||
try:
|
||||
engine = db.get_engine()
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Could not get engine for system_settings autotask 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_system_settings_autotask_integration completed.")
|
||||
except Exception as 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}")
|
||||
|
||||
|
||||
def migrate_tickets_resolved_origin() -> None:
|
||||
"""Add resolved_origin column to tickets if missing.
|
||||
|
||||
This column stores the origin of the resolution (psa | backupchecks).
|
||||
"""
|
||||
|
||||
table = "tickets"
|
||||
column = "resolved_origin"
|
||||
|
||||
try:
|
||||
engine = db.get_engine()
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Could not get engine for tickets resolved_origin migration: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
if _column_exists(table, column):
|
||||
print("[migrations] tickets.resolved_origin already exists.")
|
||||
return
|
||||
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN {column} VARCHAR(32)'))
|
||||
|
||||
print("[migrations] migrate_tickets_resolved_origin completed.")
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Failed to migrate tickets.resolved_origin: {exc}")
|
||||
|
||||
|
||||
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.begin() as conn:
|
||||
existing = _get_table_columns(conn, table)
|
||||
|
||||
if "autotask_ticket_id" not in existing:
|
||||
print("[migrations] Adding job_runs.autotask_ticket_id column...")
|
||||
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN autotask_ticket_id INTEGER'))
|
||||
|
||||
if "autotask_ticket_number" not in existing:
|
||||
print("[migrations] Adding job_runs.autotask_ticket_number column...")
|
||||
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN autotask_ticket_number VARCHAR(64)'))
|
||||
|
||||
if "autotask_ticket_created_at" not in existing:
|
||||
print("[migrations] Adding job_runs.autotask_ticket_created_at column...")
|
||||
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN autotask_ticket_created_at TIMESTAMP'))
|
||||
|
||||
if "autotask_ticket_created_by_user_id" not in existing:
|
||||
print("[migrations] Adding job_runs.autotask_ticket_created_by_user_id column...")
|
||||
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN autotask_ticket_created_by_user_id INTEGER'))
|
||||
|
||||
print("[migrations] migrate_job_runs_autotask_ticket_fields completed.")
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Failed to migrate job_runs Autotask ticket fields: {exc}")
|
||||
|
||||
|
||||
def migrate_job_runs_autotask_ticket_deleted_fields() -> None:
|
||||
"""Add Autotask deleted ticket tracking fields to job_runs if missing.
|
||||
|
||||
Columns:
|
||||
- job_runs.autotask_ticket_deleted_at (TIMESTAMP NULL)
|
||||
- job_runs.autotask_ticket_deleted_by_resource_id (INTEGER NULL)
|
||||
"""
|
||||
|
||||
table = "job_runs"
|
||||
try:
|
||||
engine = db.get_engine()
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Could not get engine for job_runs Autotask deleted fields migration: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
existing = _get_table_columns(conn, table)
|
||||
|
||||
if "autotask_ticket_deleted_at" not in existing:
|
||||
print("[migrations] Adding job_runs.autotask_ticket_deleted_at column...")
|
||||
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN autotask_ticket_deleted_at TIMESTAMP'))
|
||||
|
||||
if "autotask_ticket_deleted_by_resource_id" not in existing:
|
||||
print("[migrations] Adding job_runs.autotask_ticket_deleted_by_resource_id column...")
|
||||
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN autotask_ticket_deleted_by_resource_id INTEGER'))
|
||||
|
||||
print("[migrations] migrate_job_runs_autotask_ticket_deleted_fields completed.")
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Failed to migrate job_runs Autotask deleted fields: {exc}")
|
||||
|
||||
|
||||
def migrate_job_runs_autotask_ticket_deleted_by_name_fields() -> None:
|
||||
"""Add Autotask deleted ticket by-name fields to job_runs if missing.
|
||||
|
||||
Columns:
|
||||
- job_runs.autotask_ticket_deleted_by_first_name (VARCHAR(255) NULL)
|
||||
- job_runs.autotask_ticket_deleted_by_last_name (VARCHAR(255) NULL)
|
||||
"""
|
||||
|
||||
table = "job_runs"
|
||||
try:
|
||||
engine = db.get_engine()
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Could not get engine for job_runs Autotask deleted by-name migration: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
existing = _get_table_columns(conn, table)
|
||||
|
||||
if "autotask_ticket_deleted_by_first_name" not in existing:
|
||||
print("[migrations] Adding job_runs.autotask_ticket_deleted_by_first_name column...")
|
||||
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN autotask_ticket_deleted_by_first_name VARCHAR(255)'))
|
||||
|
||||
if "autotask_ticket_deleted_by_last_name" not in existing:
|
||||
print("[migrations] Adding job_runs.autotask_ticket_deleted_by_last_name column...")
|
||||
conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN autotask_ticket_deleted_by_last_name VARCHAR(255)'))
|
||||
|
||||
print("[migrations] migrate_job_runs_autotask_ticket_deleted_by_name_fields completed.")
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Failed to migrate job_runs Autotask deleted by-name fields: {exc}")
|
||||
|
||||
|
||||
def migrate_mail_messages_columns() -> None:
|
||||
"""Ensure new columns on mail_messages exist.
|
||||
@ -779,6 +1033,8 @@ def run_migrations() -> None:
|
||||
migrate_system_settings_auto_import_cutoff_date()
|
||||
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()
|
||||
@ -793,14 +1049,20 @@ def run_migrations() -> None:
|
||||
migrate_feedback_tables()
|
||||
migrate_feedback_replies_table()
|
||||
migrate_tickets_active_from_date()
|
||||
migrate_tickets_resolved_origin()
|
||||
migrate_remarks_active_from_date()
|
||||
migrate_overrides_match_columns()
|
||||
migrate_job_runs_review_tracking()
|
||||
migrate_job_runs_override_metadata()
|
||||
migrate_job_runs_autotask_ticket_fields()
|
||||
migrate_job_runs_autotask_ticket_deleted_fields()
|
||||
migrate_job_runs_autotask_ticket_deleted_by_name_fields()
|
||||
migrate_jobs_archiving()
|
||||
migrate_news_tables()
|
||||
migrate_reporting_tables()
|
||||
migrate_reporting_report_config()
|
||||
migrate_performance_indexes()
|
||||
migrate_system_settings_require_daily_dashboard_visit()
|
||||
print("[migrations] All migrations completed.")
|
||||
|
||||
|
||||
@ -844,6 +1106,78 @@ def migrate_jobs_archiving() -> None:
|
||||
print("[migrations] migrate_jobs_archiving completed.")
|
||||
|
||||
|
||||
def migrate_system_settings_require_daily_dashboard_visit() -> None:
|
||||
"""Add require_daily_dashboard_visit column to system_settings if missing.
|
||||
|
||||
When enabled, authenticated users are redirected to the dashboard on
|
||||
their first page view each day.
|
||||
"""
|
||||
table = "system_settings"
|
||||
column = "require_daily_dashboard_visit"
|
||||
|
||||
try:
|
||||
engine = db.get_engine()
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Could not get engine for system_settings require_daily_dashboard_visit migration: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
if _column_exists(table, column):
|
||||
print("[migrations] system_settings.require_daily_dashboard_visit already exists.")
|
||||
return
|
||||
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
f'ALTER TABLE "{table}" ADD COLUMN {column} BOOLEAN NOT NULL DEFAULT FALSE'
|
||||
)
|
||||
)
|
||||
|
||||
print("[migrations] migrate_system_settings_require_daily_dashboard_visit completed.")
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Failed to migrate system_settings.require_daily_dashboard_visit: {exc}")
|
||||
|
||||
|
||||
def migrate_performance_indexes() -> None:
|
||||
"""Add performance indexes for frequently queried foreign key columns.
|
||||
|
||||
These indexes significantly improve query performance on slow storage,
|
||||
especially for Daily Jobs and Run Checks pages.
|
||||
|
||||
This migration is idempotent.
|
||||
"""
|
||||
try:
|
||||
engine = db.get_engine()
|
||||
except Exception as exc:
|
||||
print(f"[migrations] Could not get engine for performance indexes migration: {exc}")
|
||||
return
|
||||
|
||||
with engine.begin() as conn:
|
||||
# JobRun indexes
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_run_job_id ON job_runs (job_id)'))
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_run_job_id_run_at ON job_runs (job_id, run_at)'))
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_run_job_id_reviewed_at ON job_runs (job_id, reviewed_at)'))
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_run_mail_message_id ON job_runs (mail_message_id)'))
|
||||
|
||||
# MailMessage indexes
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_mail_message_job_id ON mail_messages (job_id)'))
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_mail_message_location ON mail_messages (location)'))
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_mail_message_job_id_location ON mail_messages (job_id, location)'))
|
||||
|
||||
# MailObject indexes
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_mail_object_mail_message_id ON mail_objects (mail_message_id)'))
|
||||
|
||||
# TicketScope indexes
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_ticket_scope_ticket_id ON ticket_scopes (ticket_id)'))
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_ticket_scope_job_id ON ticket_scopes (job_id)'))
|
||||
|
||||
# RemarkScope indexes
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_remark_scope_remark_id ON remark_scopes (remark_id)'))
|
||||
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_remark_scope_job_id ON remark_scopes (job_id)'))
|
||||
|
||||
print("[migrations] migrate_performance_indexes completed.")
|
||||
|
||||
|
||||
def migrate_reporting_report_config() -> None:
|
||||
"""Add report_definitions.report_config column if missing.
|
||||
|
||||
|
||||
@ -107,6 +107,33 @@ class SystemSettings(db.Model):
|
||||
# UI display timezone (IANA name). Used for rendering times in the web interface.
|
||||
ui_timezone = db.Column(db.String(64), nullable=False, default="Europe/Amsterdam")
|
||||
|
||||
# Navigation behavior: require visiting dashboard first each day.
|
||||
# When enabled, authenticated users are redirected to the dashboard on
|
||||
# their first page view each day before they can navigate elsewhere.
|
||||
require_daily_dashboard_visit = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
# Autotask integration settings
|
||||
autotask_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
||||
autotask_environment = db.Column(db.String(32), nullable=True) # sandbox | production
|
||||
autotask_api_username = db.Column(db.String(255), nullable=True)
|
||||
autotask_api_password = db.Column(db.String(255), nullable=True)
|
||||
autotask_tracking_identifier = db.Column(db.String(255), nullable=True)
|
||||
autotask_base_url = db.Column(db.String(512), nullable=True) # Backupchecks base URL for deep links
|
||||
|
||||
# Autotask defaults (IDs are leading)
|
||||
autotask_default_queue_id = db.Column(db.Integer, nullable=True)
|
||||
autotask_default_ticket_source_id = db.Column(db.Integer, nullable=True)
|
||||
autotask_default_ticket_status = db.Column(db.Integer, nullable=True)
|
||||
autotask_priority_warning = db.Column(db.Integer, nullable=True)
|
||||
autotask_priority_error = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Cached reference data (for dropdowns)
|
||||
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(
|
||||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
@ -132,6 +159,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
|
||||
@ -246,6 +281,15 @@ 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)
|
||||
autotask_ticket_deleted_at = db.Column(db.DateTime, nullable=True)
|
||||
autotask_ticket_deleted_by_resource_id = db.Column(db.Integer, nullable=True)
|
||||
autotask_ticket_deleted_by_first_name = db.Column(db.String(255), nullable=True)
|
||||
autotask_ticket_deleted_by_last_name = db.Column(db.String(255), nullable=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(
|
||||
@ -258,6 +302,7 @@ 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):
|
||||
@ -383,6 +428,8 @@ class Ticket(db.Model):
|
||||
# Audit timestamp: when the ticket was created (UTC, naive)
|
||||
start_date = db.Column(db.DateTime, nullable=False)
|
||||
resolved_at = db.Column(db.DateTime)
|
||||
# Resolution origin for audit/UI: psa | backupchecks
|
||||
resolved_origin = db.Column(db.String(32))
|
||||
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)
|
||||
|
||||
|
||||
235
containers/backupchecks/src/backend/app/ticketing_utils.py
Normal file
235
containers/backupchecks/src/backend/app/ticketing_utils.py
Normal file
@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, date, timezone
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from .database import db
|
||||
from .models import Job, JobRun, SystemSettings, Ticket, TicketJobRun, TicketScope
|
||||
|
||||
|
||||
def _get_ui_timezone_name() -> str:
|
||||
"""Return the configured UI timezone name (IANA), with a safe fallback.
|
||||
|
||||
NOTE: This must not import from any routes_* modules to avoid circular imports.
|
||||
"""
|
||||
|
||||
try:
|
||||
settings = SystemSettings.query.first()
|
||||
name = (getattr(settings, "ui_timezone", None) or "").strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return (current_app.config.get("TIMEZONE") or "Europe/Amsterdam").strip()
|
||||
except Exception:
|
||||
return "Europe/Amsterdam"
|
||||
|
||||
|
||||
def _to_ui_date(dt_utc_naive: datetime | None) -> date | None:
|
||||
"""Convert a naive UTC datetime to a UI-local date."""
|
||||
if not dt_utc_naive:
|
||||
return None
|
||||
|
||||
try:
|
||||
tz = ZoneInfo(_get_ui_timezone_name())
|
||||
except Exception:
|
||||
tz = None
|
||||
|
||||
if not tz:
|
||||
return dt_utc_naive.date()
|
||||
|
||||
try:
|
||||
if dt_utc_naive.tzinfo is None:
|
||||
dt_utc = dt_utc_naive.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt_utc = dt_utc_naive.astimezone(timezone.utc)
|
||||
return dt_utc.astimezone(tz).date()
|
||||
except Exception:
|
||||
return dt_utc_naive.date()
|
||||
|
||||
|
||||
def ensure_internal_ticket_for_job(
|
||||
*,
|
||||
ticket_code: str,
|
||||
title: Optional[str],
|
||||
description: str,
|
||||
job: Job,
|
||||
active_from_dt: Optional[datetime],
|
||||
start_dt: Optional[datetime] = None,
|
||||
) -> Ticket:
|
||||
"""Create/reuse an internal Ticket and ensure a job scope exists.
|
||||
|
||||
This mirrors the legacy manual ticket workflow but allows arbitrary ticket codes
|
||||
(e.g. Autotask ticket numbers).
|
||||
"""
|
||||
|
||||
now = datetime.utcnow()
|
||||
start_dt = start_dt or now
|
||||
|
||||
code = (ticket_code or "").strip().upper()
|
||||
if not code:
|
||||
raise ValueError("ticket_code is required")
|
||||
|
||||
ticket = Ticket.query.filter_by(ticket_code=code).first()
|
||||
if not ticket:
|
||||
ticket = Ticket(
|
||||
ticket_code=code,
|
||||
title=title,
|
||||
description=description,
|
||||
active_from_date=_to_ui_date(active_from_dt) or _to_ui_date(start_dt) or start_dt.date(),
|
||||
start_date=start_dt,
|
||||
resolved_at=None,
|
||||
)
|
||||
db.session.add(ticket)
|
||||
db.session.flush()
|
||||
|
||||
# Ensure an open job scope exists
|
||||
scope = TicketScope.query.filter_by(ticket_id=ticket.id, scope_type="job", job_id=job.id).first()
|
||||
if not scope:
|
||||
scope = TicketScope(
|
||||
ticket_id=ticket.id,
|
||||
scope_type="job",
|
||||
customer_id=job.customer_id,
|
||||
backup_software=job.backup_software,
|
||||
backup_type=job.backup_type,
|
||||
job_id=job.id,
|
||||
job_name_match=job.job_name,
|
||||
job_name_match_mode="exact",
|
||||
resolved_at=None,
|
||||
)
|
||||
db.session.add(scope)
|
||||
else:
|
||||
# Re-open and refresh scope metadata (legacy behavior)
|
||||
scope.resolved_at = None
|
||||
scope.customer_id = job.customer_id
|
||||
scope.backup_software = job.backup_software
|
||||
scope.backup_type = job.backup_type
|
||||
scope.job_name_match = job.job_name
|
||||
scope.job_name_match_mode = "exact"
|
||||
|
||||
return ticket
|
||||
|
||||
|
||||
def ensure_ticket_jobrun_links(
|
||||
*,
|
||||
ticket_id: int,
|
||||
run_ids: Iterable[int],
|
||||
link_source: str,
|
||||
) -> None:
|
||||
"""Idempotently ensure TicketJobRun links exist for all provided run IDs."""
|
||||
|
||||
run_ids_list = [int(x) for x in (run_ids or []) if x is not None]
|
||||
if not run_ids_list:
|
||||
return
|
||||
|
||||
existing = set()
|
||||
try:
|
||||
rows = (
|
||||
db.session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT job_run_id
|
||||
FROM ticket_job_runs
|
||||
WHERE ticket_id = :ticket_id
|
||||
AND job_run_id = ANY(:run_ids)
|
||||
"""
|
||||
),
|
||||
{"ticket_id": int(ticket_id), "run_ids": run_ids_list},
|
||||
)
|
||||
.fetchall()
|
||||
)
|
||||
existing = {int(rid) for (rid,) in rows if rid is not None}
|
||||
except Exception:
|
||||
existing = set()
|
||||
|
||||
for rid in run_ids_list:
|
||||
if rid in existing:
|
||||
continue
|
||||
db.session.add(TicketJobRun(ticket_id=int(ticket_id), job_run_id=int(rid), link_source=link_source))
|
||||
|
||||
|
||||
def link_open_internal_tickets_to_run(*, run: JobRun, job: Job) -> None:
|
||||
"""When a new run is created, link any currently open internal tickets for the job.
|
||||
|
||||
This restores legacy behavior where a ticket stays visible for new runs until resolved.
|
||||
Additionally (best-effort), if the job already has Autotask linkage on previous runs,
|
||||
propagate that to the new run so PSA polling remains consistent.
|
||||
"""
|
||||
|
||||
if not run or not getattr(run, "id", None) or not job or not getattr(job, "id", None):
|
||||
return
|
||||
|
||||
ui_tz = _get_ui_timezone_name()
|
||||
run_date = _to_ui_date(getattr(run, "run_at", None)) or _to_ui_date(datetime.utcnow())
|
||||
|
||||
# Find open tickets scoped to this job for the run date window.
|
||||
# This matches the logic used by Job Details and Run Checks indicators.
|
||||
rows = []
|
||||
try:
|
||||
rows = (
|
||||
db.session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.id, t.ticket_code
|
||||
FROM tickets t
|
||||
JOIN ticket_scopes ts ON ts.ticket_id = t.id
|
||||
WHERE ts.job_id = :job_id
|
||||
AND t.active_from_date <= :run_date
|
||||
AND (
|
||||
COALESCE(ts.resolved_at, t.resolved_at) IS NULL
|
||||
OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
|
||||
)
|
||||
ORDER BY t.start_date DESC, t.id DESC
|
||||
"""
|
||||
),
|
||||
{"job_id": int(job.id), "run_date": run_date, "ui_tz": ui_tz},
|
||||
)
|
||||
.fetchall()
|
||||
)
|
||||
except Exception:
|
||||
rows = []
|
||||
|
||||
if not rows:
|
||||
return
|
||||
|
||||
# Link all open tickets to this run (idempotent)
|
||||
for tid, _code in rows:
|
||||
if not TicketJobRun.query.filter_by(ticket_id=int(tid), job_run_id=int(run.id)).first():
|
||||
db.session.add(TicketJobRun(ticket_id=int(tid), job_run_id=int(run.id), link_source="inherit"))
|
||||
|
||||
# Best-effort: propagate Autotask linkage if present on prior runs for the same ticket code.
|
||||
# This allows new runs to keep the PSA ticket reference without requiring UI changes.
|
||||
try:
|
||||
if getattr(run, "autotask_ticket_id", None):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Use the newest ticket code to find a matching prior Autotask-linked run.
|
||||
newest_code = (rows[0][1] or "").strip()
|
||||
if not newest_code:
|
||||
return
|
||||
|
||||
prior = (
|
||||
JobRun.query.filter(JobRun.job_id == job.id)
|
||||
.filter(JobRun.autotask_ticket_id.isnot(None))
|
||||
.filter(JobRun.autotask_ticket_number == newest_code)
|
||||
.order_by(JobRun.id.desc())
|
||||
.first()
|
||||
)
|
||||
if prior and getattr(prior, "autotask_ticket_id", None):
|
||||
run.autotask_ticket_id = prior.autotask_ticket_id
|
||||
run.autotask_ticket_number = prior.autotask_ticket_number
|
||||
run.autotask_ticket_created_at = getattr(prior, "autotask_ticket_created_at", None)
|
||||
run.autotask_ticket_created_by_user_id = getattr(prior, "autotask_ticket_created_by_user_id", None)
|
||||
except Exception:
|
||||
return
|
||||
212
containers/backupchecks/src/static/css/changelog.css
Normal file
212
containers/backupchecks/src/static/css/changelog.css
Normal file
@ -0,0 +1,212 @@
|
||||
/* Changelog specific styling */
|
||||
|
||||
/* Navigation sidebar */
|
||||
.changelog-nav {
|
||||
padding: 1rem;
|
||||
background: var(--bs-body-bg);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.changelog-nav .changelog-nav-link {
|
||||
padding: 0.15rem 0.5rem !important;
|
||||
margin-bottom: 0.15rem !important;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--bs-body-color);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease-in-out;
|
||||
font-size: 0.85rem !important;
|
||||
line-height: 1.1 !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.changelog-nav .changelog-nav-link span {
|
||||
font-size: 0.7rem !important;
|
||||
margin-top: 0;
|
||||
line-height: 1 !important;
|
||||
display: block;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.changelog-nav-link:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.changelog-nav-link:active,
|
||||
.changelog-nav-link.active {
|
||||
background: var(--bs-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Version cards */
|
||||
.changelog-version-card {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
transition: box-shadow 0.2s ease-in-out;
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
|
||||
.changelog-version-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.changelog-version-card .card-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: linear-gradient(135deg, var(--bs-primary) 0%, var(--bs-primary-dark, #0056b3) 100%);
|
||||
}
|
||||
|
||||
.changelog-version-card .card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Summary section */
|
||||
.changelog-summary {
|
||||
padding: 1rem;
|
||||
background: var(--bs-light);
|
||||
border-left: 4px solid var(--bs-primary);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .changelog-summary {
|
||||
background: var(--bs-dark);
|
||||
}
|
||||
|
||||
.changelog-summary .lead {
|
||||
margin-bottom: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Section styling */
|
||||
.changelog-section {
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.changelog-section:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Type badges */
|
||||
.changelog-badge-feature {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.changelog-badge-improvement {
|
||||
background: linear-gradient(135deg, #17a2b8 0%, #20c997 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.changelog-badge-fixed {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #fd7e14 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.changelog-badge-added {
|
||||
background: linear-gradient(135deg, #007bff 0%, #6610f2 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.changelog-badge-removed {
|
||||
background: linear-gradient(135deg, #6c757d 0%, #495057 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.changelog-badge-changed {
|
||||
background: linear-gradient(135deg, #ffc107 0%, #ff9800 100%);
|
||||
color: #212529;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.changelog-badge-documentation {
|
||||
background: linear-gradient(135deg, #6f42c1 0%, #e83e8c 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Subsection styling */
|
||||
.changelog-subsection {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.changelog-subsection h4 {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* List styling */
|
||||
.changelog-list {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.changelog-list li {
|
||||
padding: 0.4rem 0 0.4rem 1.75rem;
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.changelog-list li::before {
|
||||
content: "●";
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
color: var(--bs-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.changelog-list li:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Nested lists (indented items) */
|
||||
.changelog-list li:has(+ li) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 767.98px) {
|
||||
.changelog-version-card .card-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.changelog-version-card .card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.changelog-summary {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.changelog-list li {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
@ -12,6 +12,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/status-text.css') }}" />
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}" />
|
||||
{% block head %}{% endblock %}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
@ -1,32 +1,97 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/changelog.css') }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div class="row">
|
||||
<!-- Sidebar with version navigation -->
|
||||
<div class="col-lg-3 col-md-4 d-none d-md-block">
|
||||
<div class="changelog-nav sticky-top" style="top: 80px;">
|
||||
<h6 class="text-body-secondary text-uppercase mb-3">Versions</h6>
|
||||
<nav class="nav flex-column">
|
||||
{% for version_data in changelog_versions %}
|
||||
<a class="nav-link changelog-nav-link" href="#{{ version_data.version }}">
|
||||
{{ version_data.version }}
|
||||
<span class="text-body-secondary small d-block">{{ version_data.date }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="col-lg-9 col-md-8">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">Changelog</h1>
|
||||
<div class="text-body-secondary">Loaded live from the repository.</div>
|
||||
<div class="text-body-secondary">Release history and updates</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if changelog_versions %}
|
||||
{% for version_data in changelog_versions %}
|
||||
<div class="card changelog-version-card mb-4" id="{{ version_data.version }}">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h2 class="h4 mb-0">{{ version_data.version }}</h2>
|
||||
</div>
|
||||
{% if changelog_source_url %}
|
||||
<div class="text-end">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{{ changelog_source_url }}" target="_blank" rel="noopener">
|
||||
View source
|
||||
</a>
|
||||
<span class="badge bg-light text-dark">{{ version_data.date }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if changelog_error %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
{{ changelog_error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if changelog_html %}
|
||||
<div class="markdown-content">{{ changelog_html | safe }}</div>
|
||||
{% if version_data.summary %}
|
||||
<div class="changelog-summary mb-4">
|
||||
<p class="lead">{{ version_data.summary }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for section in version_data.sections %}
|
||||
<div class="changelog-section mb-4">
|
||||
<h3 class="h5 mb-3">
|
||||
{% if section.type %}
|
||||
<span class="badge changelog-badge-{{ section.type }}">{{ section.title }}</span>
|
||||
{% else %}
|
||||
<div class="text-body-secondary">No changelog content available.</div>
|
||||
{{ section.title }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
|
||||
{% if section.subsections %}
|
||||
{% for subsection in section.subsections %}
|
||||
<div class="changelog-subsection mb-3">
|
||||
{% if subsection.subtitle %}
|
||||
<h4 class="h6 text-body-secondary mb-2">{{ subsection.subtitle }}</h4>
|
||||
{% endif %}
|
||||
{% if subsection.changes %}
|
||||
<ul class="changelog-list">
|
||||
{% for change in subsection.changes %}
|
||||
<li>{{ change }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% elif section.changes %}
|
||||
<ul class="changelog-list">
|
||||
{% for change in section.changes %}
|
||||
<li>{{ change }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
No changelog entries available.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -19,6 +19,11 @@
|
||||
</form>
|
||||
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('main.customers_export') }}">Export CSV</a>
|
||||
|
||||
{% if autotask_enabled and autotask_configured %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="autotaskRefreshAllMappingsBtn" style="white-space: nowrap;">Refresh all Autotask mappings</button>
|
||||
<span class="small text-muted" id="autotaskRefreshAllMappingsMsg"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -29,6 +34,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 +53,7 @@
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if c.job_count > 0 %}
|
||||
{{ c.job_count }}
|
||||
@ -53,6 +61,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 +101,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 +124,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 +172,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 +224,133 @@
|
||||
var nameInput = document.getElementById("edit_customer_name");
|
||||
var activeInput = document.getElementById("edit_customer_active");
|
||||
|
||||
// Top-level refresh-all (only present when integration is enabled/configured)
|
||||
var refreshAllBtn = document.getElementById("autotaskRefreshAllMappingsBtn");
|
||||
var refreshAllMsg = document.getElementById("autotaskRefreshAllMappingsMsg");
|
||||
|
||||
// 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 setRefreshAllMsg(text, isError) {
|
||||
if (!refreshAllMsg) {
|
||||
return;
|
||||
}
|
||||
refreshAllMsg.textContent = text || "";
|
||||
if (isError) {
|
||||
refreshAllMsg.classList.remove("text-muted");
|
||||
refreshAllMsg.classList.add("text-danger");
|
||||
} else {
|
||||
refreshAllMsg.classList.remove("text-danger");
|
||||
refreshAllMsg.classList.add("text-muted");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (refreshAllBtn) {
|
||||
refreshAllBtn.addEventListener("click", async function () {
|
||||
if (!confirm("Refresh mapping status for all mapped customers?")) {
|
||||
return;
|
||||
}
|
||||
refreshAllBtn.disabled = true;
|
||||
setRefreshAllMsg("Refreshing...", false);
|
||||
try {
|
||||
var data = await postJson("/api/customers/autotask-mapping/refresh-all", {});
|
||||
var counts = (data && data.counts) ? data.counts : null;
|
||||
if (counts) {
|
||||
setRefreshAllMsg(
|
||||
"Done. OK: " + (counts.ok || 0) + ", Renamed: " + (counts.renamed || 0) + ", Missing: " + (counts.missing || 0) + ", Invalid: " + (counts.invalid || 0) + ".",
|
||||
false
|
||||
);
|
||||
} else {
|
||||
setRefreshAllMsg("Done.", false);
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
setRefreshAllMsg(e && e.message ? e.message : "Refresh failed.", true);
|
||||
refreshAllBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var editButtons = document.querySelectorAll(".customer-edit-btn");
|
||||
editButtons.forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
@ -165,8 +364,155 @@
|
||||
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();
|
||||
}
|
||||
if (atSearchInput) atSearchInput.value = '';
|
||||
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);
|
||||
|
||||
// Auto-search for similar companies if not yet mapped
|
||||
if (!atCompanyId && name && atSearchInput) {
|
||||
atSearchInput.value = name;
|
||||
performAutotaskSearch(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reusable Autotask search function
|
||||
async function performAutotaskSearch(query) {
|
||||
if (!atResults) return;
|
||||
|
||||
var q = (query || "").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 (atSearchBtn && atSearchInput && atResults) {
|
||||
atSearchBtn.addEventListener("click", async function () {
|
||||
var q = (atSearchInput.value || "").trim();
|
||||
await performAutotaskSearch(q);
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@ -216,14 +216,25 @@
|
||||
<div class="row g-2 align-items-start">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="border rounded p-2">
|
||||
{% if autotask_enabled %}
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="fw-semibold">Autotask ticket</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-success" id="rcm_autotask_resolve_note">Resolve</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="rcm_autotask_link_existing">Link existing</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_autotask_create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 small" id="rcm_autotask_info"></div>
|
||||
<div class="mt-2 small text-muted" id="rcm_autotask_status"></div>
|
||||
{% else %}
|
||||
<div class="fw-semibold">New ticket</div>
|
||||
<div class="d-flex gap-2 mt-1">
|
||||
<input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
|
||||
<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 class="mt-1 small text-muted" id="rcm_ticket_status"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
@ -285,6 +296,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="modal fade" id="autotaskLinkModal" tabindex="-1" aria-labelledby="autotaskLinkModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="autotaskLinkModalLabel">Link existing Autotask ticket</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<input type="text" class="form-control" id="atl_search" placeholder="Search by ticket number or title" />
|
||||
<button type="button" class="btn btn-outline-secondary" id="atl_refresh">Refresh</button>
|
||||
</div>
|
||||
<div class="small text-muted mb-2" id="atl_status"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 1%;"></th>
|
||||
<th>Ticket</th>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="atl_tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-muted" id="atl_empty" style="display:none;">No tickets found.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var table = document.getElementById('runChecksTable');
|
||||
@ -299,6 +347,8 @@
|
||||
var currentRunId = null;
|
||||
var currentPayload = null;
|
||||
|
||||
var autotaskEnabled = {{ 'true' if autotask_enabled else 'false' }};
|
||||
|
||||
var btnMarkAllReviewed = document.getElementById('rcm_mark_all_reviewed');
|
||||
var btnMarkSuccessOverride = document.getElementById('rcm_mark_success_override');
|
||||
|
||||
@ -841,28 +891,104 @@ table.addEventListener('change', function (e) {
|
||||
}
|
||||
|
||||
function bindInlineCreateForms() {
|
||||
var btnAutotask = document.getElementById('rcm_autotask_create');
|
||||
var btnAutotaskResolveNote = document.getElementById('rcm_autotask_resolve_note');
|
||||
var btnAutotaskLink = document.getElementById('rcm_autotask_link_existing');
|
||||
var atInfo = document.getElementById('rcm_autotask_info');
|
||||
var atStatus = document.getElementById('rcm_autotask_status');
|
||||
|
||||
var btnTicket = document.getElementById('rcm_ticket_save');
|
||||
var btnRemark = document.getElementById('rcm_remark_save');
|
||||
var tCode = document.getElementById('rcm_ticket_code');
|
||||
var tStatus = document.getElementById('rcm_ticket_status');
|
||||
|
||||
var btnRemark = document.getElementById('rcm_remark_save');
|
||||
var rBody = document.getElementById('rcm_remark_body');
|
||||
var rStatus = document.getElementById('rcm_remark_status');
|
||||
|
||||
function clearStatus() {
|
||||
if (atStatus) atStatus.textContent = '';
|
||||
if (tStatus) tStatus.textContent = '';
|
||||
if (rStatus) rStatus.textContent = '';
|
||||
}
|
||||
|
||||
function setDisabled(disabled) {
|
||||
if (btnAutotask) btnAutotask.disabled = disabled;
|
||||
if (btnTicket) btnTicket.disabled = disabled;
|
||||
if (btnRemark) btnRemark.disabled = disabled;
|
||||
if (tCode) tCode.disabled = disabled;
|
||||
if (btnRemark) btnRemark.disabled = disabled;
|
||||
if (rBody) rBody.disabled = disabled;
|
||||
}
|
||||
|
||||
window.__rcmSetCreateDisabled = setDisabled;
|
||||
window.__rcmClearCreateStatus = clearStatus;
|
||||
|
||||
function renderAutotaskInfo(run) {
|
||||
if (!atInfo) return;
|
||||
var num = (run && run.autotask_ticket_number) ? String(run.autotask_ticket_number) : '';
|
||||
var isResolved = !!(run && run.autotask_ticket_is_resolved);
|
||||
var origin = (run && run.autotask_ticket_resolved_origin) ? String(run.autotask_ticket_resolved_origin) : '';
|
||||
var isDeleted = !!(run && run.autotask_ticket_is_deleted);
|
||||
var deletedAt = (run && run.autotask_ticket_deleted_at) ? String(run.autotask_ticket_deleted_at) : '';
|
||||
var deletedBy = (run && run.autotask_ticket_deleted_by_resource_id) ? String(run.autotask_ticket_deleted_by_resource_id) : '';
|
||||
var deletedByFirst = (run && run.autotask_ticket_deleted_by_first_name) ? String(run.autotask_ticket_deleted_by_first_name) : '';
|
||||
var deletedByLast = (run && run.autotask_ticket_deleted_by_last_name) ? String(run.autotask_ticket_deleted_by_last_name) : '';
|
||||
|
||||
if (num) {
|
||||
var extra = '';
|
||||
if (isDeleted) {
|
||||
var meta = '';
|
||||
if (deletedAt) meta += '<div class="text-muted">Deleted at: ' + escapeHtml(deletedAt) + '</div>';
|
||||
if (deletedByFirst || deletedByLast) {
|
||||
meta += '<div class="text-muted">Deleted by: ' + escapeHtml((deletedByFirst + ' ' + deletedByLast).trim()) + '</div>';
|
||||
} else if (deletedBy) {
|
||||
meta += '<div class="text-muted">Deleted by resource ID: ' + escapeHtml(deletedBy) + '</div>';
|
||||
}
|
||||
extra = '<div class="mt-1"><span class="badge bg-danger">Deleted in PSA</span></div>' + meta;
|
||||
} else if (isResolved && origin === 'psa') {
|
||||
extra = '<div class="mt-1"><span class="badge bg-secondary">Resolved by PSA</span></div>';
|
||||
}
|
||||
atInfo.innerHTML = '<div><strong>Ticket:</strong> ' + escapeHtml(num) + '</div>' + extra;
|
||||
} 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>';
|
||||
}
|
||||
|
||||
if (btnAutotask) {
|
||||
if (run && run.autotask_ticket_id && (isResolved || isDeleted)) btnAutotask.textContent = 'Create new';
|
||||
else btnAutotask.textContent = 'Create';
|
||||
}
|
||||
|
||||
if (btnAutotaskLink) {
|
||||
var hasAtTicket = !!(run && run.autotask_ticket_id);
|
||||
// Link existing is only meaningful when there is no PSA ticket linked to this run.
|
||||
btnAutotaskLink.style.display = hasAtTicket ? 'none' : '';
|
||||
}
|
||||
|
||||
if (btnAutotaskResolveNote) {
|
||||
var hasTicket = !!(run && run.autotask_ticket_id);
|
||||
// Resolve note is only meaningful when there is an active linked PSA ticket.
|
||||
btnAutotaskResolveNote.style.display = (hasTicket && !isResolved && !isDeleted) ? '' : 'none';
|
||||
}
|
||||
if (btnAutotaskLink) btnAutotaskLink.disabled = !currentRunId;
|
||||
if (btnAutotaskResolveNote) btnAutotaskResolveNote.disabled = !currentRunId;
|
||||
|
||||
}
|
||||
window.__rcmRenderAutotaskInfo = renderAutotaskInfo;
|
||||
|
||||
window.__rcmSetAutotaskCreateLabel = function (run) {
|
||||
if (!btnAutotask) return;
|
||||
var hasTicket = !!(run && run.autotask_ticket_id);
|
||||
var isResolved = !!(run && run.autotask_ticket_is_resolved);
|
||||
var isDeleted = !!(run && run.autotask_ticket_is_deleted);
|
||||
btnAutotask.textContent = (hasTicket && (isResolved || isDeleted)) ? 'Create new' : 'Create';
|
||||
};
|
||||
|
||||
|
||||
function isValidTicketCode(code) {
|
||||
return /^T\d{8}\.\d{4}$/.test(code);
|
||||
}
|
||||
|
||||
if (btnTicket) {
|
||||
btnTicket.addEventListener('click', function () {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
@ -873,7 +999,7 @@ if (!ticket_code) {
|
||||
else alert('Ticket number is required.');
|
||||
return;
|
||||
}
|
||||
if (!/^T\d{8}\.\d{4}$/.test(ticket_code)) {
|
||||
if (!isValidTicketCode(ticket_code)) {
|
||||
if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.';
|
||||
else alert('Invalid ticket number format. Expected TYYYYMMDD.####.');
|
||||
return;
|
||||
@ -895,6 +1021,198 @@ if (tStatus) tStatus.textContent = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (btnAutotaskLink) {
|
||||
var linkModalEl = document.getElementById('autotaskLinkModal');
|
||||
// Avoid stacked Bootstrap modals: temporarily hide the main Run Checks modal
|
||||
// and re-open it when the Autotask link modal is closed.
|
||||
var mainModalEl = document.getElementById('runChecksModal');
|
||||
var reopenMainAfterLinkModal = false;
|
||||
var atlSearch = document.getElementById('atl_search');
|
||||
var atlRefresh = document.getElementById('atl_refresh');
|
||||
var atlStatus = document.getElementById('atl_status');
|
||||
var atlTbody = document.getElementById('atl_tbody');
|
||||
var atlEmpty = document.getElementById('atl_empty');
|
||||
|
||||
_modalOnHidden(linkModalEl, function () {
|
||||
if (reopenMainAfterLinkModal && mainModalEl) {
|
||||
reopenMainAfterLinkModal = false;
|
||||
// Re-open the main modal so the normal Run Checks workflow continues.
|
||||
_modalShow(mainModalEl);
|
||||
}
|
||||
});
|
||||
|
||||
function renderAtlRows(items) {
|
||||
if (!atlTbody) return;
|
||||
atlTbody.innerHTML = '';
|
||||
if (atlEmpty) atlEmpty.style.display = (items && items.length) ? 'none' : '';
|
||||
(items || []).forEach(function (t) {
|
||||
var tr = document.createElement('tr');
|
||||
var tdBtn = document.createElement('td');
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-primary';
|
||||
btn.textContent = 'Link';
|
||||
btn.addEventListener('click', function () {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
if (!confirm('Link ticket ' + (t.ticketNumber || '') + ' to this run?')) return;
|
||||
if (atlStatus) atlStatus.textContent = 'Linking...';
|
||||
apiJson('/api/run-checks/autotask-link-existing-ticket', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({run_id: currentRunId, ticket_id: t.id})
|
||||
})
|
||||
.then(function (j) {
|
||||
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
|
||||
if (atlStatus) atlStatus.textContent = '';
|
||||
_modalHide(linkModalEl);
|
||||
|
||||
// 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;
|
||||
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; }
|
||||
}
|
||||
renderRun(payload, idx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (atlStatus) atlStatus.textContent = e.message || 'Failed.';
|
||||
else alert(e.message || 'Failed.');
|
||||
});
|
||||
});
|
||||
tdBtn.appendChild(btn);
|
||||
|
||||
var tdNum = document.createElement('td');
|
||||
tdNum.textContent = (t.ticketNumber || '');
|
||||
|
||||
var tdTitle = document.createElement('td');
|
||||
tdTitle.textContent = (t.title || '');
|
||||
|
||||
var tdStatus = document.createElement('td');
|
||||
tdStatus.textContent = (t.statusLabel || t.status_label || String(t.status || ''));
|
||||
|
||||
tr.appendChild(tdBtn);
|
||||
tr.appendChild(tdNum);
|
||||
tr.appendChild(tdTitle);
|
||||
tr.appendChild(tdStatus);
|
||||
atlTbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function loadExistingTickets() {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
var q = atlSearch ? (atlSearch.value || '').trim() : '';
|
||||
if (atlStatus) atlStatus.textContent = 'Loading...';
|
||||
fetch('/api/run-checks/autotask-existing-tickets?run_id=' + encodeURIComponent(currentRunId) + '&q=' + encodeURIComponent(q))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
|
||||
if (atlStatus) atlStatus.textContent = '';
|
||||
renderAtlRows(j.items || []);
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (atlStatus) atlStatus.textContent = e.message || 'Failed.';
|
||||
renderAtlRows([]);
|
||||
});
|
||||
}
|
||||
|
||||
if (atlRefresh) {
|
||||
atlRefresh.addEventListener('click', function () { loadExistingTickets(); });
|
||||
}
|
||||
if (atlSearch) {
|
||||
atlSearch.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); loadExistingTickets(); }
|
||||
});
|
||||
}
|
||||
|
||||
btnAutotaskLink.addEventListener('click', function () {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
if (atlStatus) atlStatus.textContent = '';
|
||||
if (atlSearch) atlSearch.value = '';
|
||||
renderAtlRows([]);
|
||||
// Show the existing Run Checks popup first, then switch to the Autotask popup.
|
||||
// This prevents the main popup from breaking due to stacked modal backdrops.
|
||||
if (mainModalEl) {
|
||||
reopenMainAfterLinkModal = true;
|
||||
_modalHide(mainModalEl);
|
||||
}
|
||||
_modalShow(linkModalEl);
|
||||
loadExistingTickets();
|
||||
});
|
||||
}
|
||||
|
||||
if (btnAutotask) {
|
||||
btnAutotask.addEventListener('click', function () {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
clearStatus();
|
||||
if (atStatus) atStatus.textContent = 'Creating ticket...';
|
||||
btnAutotask.disabled = true;
|
||||
apiJson('/api/run-checks/autotask-ticket', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({run_id: 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; }
|
||||
}
|
||||
renderRun(payload, idx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (atStatus) atStatus.textContent = e.message || 'Failed.';
|
||||
else alert(e.message || 'Failed.');
|
||||
})
|
||||
.finally(function () {
|
||||
// State will be recalculated by renderRun.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (btnAutotaskResolveNote) {
|
||||
btnAutotaskResolveNote.addEventListener('click', function () {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
clearStatus();
|
||||
if (!confirm('Add an update to the existing Autotask ticket that it should be resolved?\n\nThe ticket will be closed (status Complete) if there are no time entries.\nIf time entries exist, the ticket will remain open.')) return;
|
||||
if (atStatus) atStatus.textContent = 'Posting update...';
|
||||
btnAutotaskResolveNote.disabled = true;
|
||||
apiJson('/api/run-checks/autotask-resolve-note', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({run_id: currentRunId})
|
||||
})
|
||||
.then(function (j) {
|
||||
if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.');
|
||||
if (atStatus) atStatus.textContent = (j && j.message) ? String(j.message) : 'Update posted.';
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (atStatus) atStatus.textContent = e.message || 'Failed.';
|
||||
else alert(e.message || 'Failed.');
|
||||
})
|
||||
.finally(function () {
|
||||
btnAutotaskResolveNote.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (btnRemark) {
|
||||
btnRemark.addEventListener('click', function () {
|
||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||
@ -956,7 +1274,20 @@ 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.__rcmSetAutotaskCreateLabel) window.__rcmSetAutotaskCreateLabel(run);
|
||||
if (window.__rcmSetCreateDisabled) {
|
||||
// Only disable inputs when no run is selected.
|
||||
window.__rcmSetCreateDisabled(!currentRunId);
|
||||
|
||||
if (autotaskEnabled) {
|
||||
// Autotask ticket creation is only possible when there is no active linked PSA ticket,
|
||||
// or when the existing PSA ticket is already resolved/deleted.
|
||||
var canCreateAt = !!currentRunId && (!run.autotask_ticket_id || !!run.autotask_ticket_is_resolved || !!run.autotask_ticket_is_deleted);
|
||||
var btnAutotask = document.getElementById('rcm_autotask_create');
|
||||
if (btnAutotask) btnAutotask.disabled = !canCreateAt;
|
||||
}
|
||||
}
|
||||
if (btnMarkSuccessOverride) {
|
||||
var _rs = (run.status || '').toString().toLowerCase();
|
||||
var _canOverride = !!currentRunId && !run.missed && (_rs.indexOf('override') === -1) && (_rs.indexOf('success') === -1);
|
||||
@ -1020,6 +1351,47 @@ if (tStatus) tStatus.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Modal helpers
|
||||
// The global "bootstrap" namespace is not always available (e.g. Bootstrap 4 + jQuery).
|
||||
// Use a small compatibility layer so Run Checks modals keep working.
|
||||
function _modalShow(modalEl) {
|
||||
if (!modalEl) return;
|
||||
if (window.bootstrap && window.bootstrap.Modal && window.bootstrap.Modal.getOrCreateInstance) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
||||
return;
|
||||
}
|
||||
if (window.jQuery) {
|
||||
window.jQuery(modalEl).modal('show');
|
||||
return;
|
||||
}
|
||||
// Last resort: basic display (should not normally be needed)
|
||||
modalEl.classList.add('show');
|
||||
modalEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function _modalHide(modalEl) {
|
||||
if (!modalEl) return;
|
||||
if (window.bootstrap && window.bootstrap.Modal && window.bootstrap.Modal.getOrCreateInstance) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(modalEl).hide();
|
||||
return;
|
||||
}
|
||||
if (window.jQuery) {
|
||||
window.jQuery(modalEl).modal('hide');
|
||||
return;
|
||||
}
|
||||
modalEl.classList.remove('show');
|
||||
modalEl.style.display = 'none';
|
||||
}
|
||||
|
||||
function _modalOnHidden(modalEl, handler) {
|
||||
if (!modalEl || !handler) return;
|
||||
if (window.jQuery) {
|
||||
window.jQuery(modalEl).one('hidden.bs.modal', handler);
|
||||
return;
|
||||
}
|
||||
modalEl.addEventListener('hidden.bs.modal', handler);
|
||||
}
|
||||
|
||||
function openJobModal(jobId) {
|
||||
if (!jobId) return;
|
||||
currentJobId = jobId;
|
||||
@ -1028,8 +1400,7 @@ if (tStatus) tStatus.textContent = '';
|
||||
if (btnMarkSuccessOverride) btnMarkSuccessOverride.disabled = true;
|
||||
|
||||
var modalEl = document.getElementById('runChecksModal');
|
||||
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
modal.show();
|
||||
_modalShow(modalEl);
|
||||
|
||||
document.getElementById('rcm_loading').style.display = 'block';
|
||||
document.getElementById('rcm_content').style.display = 'none';
|
||||
@ -1144,9 +1515,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);
|
||||
|
||||
@ -20,6 +20,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if section == 'imports' %}active{% endif %}" href="{{ url_for('main.settings', section='imports') }}">Imports</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if section == 'integrations' %}active{% endif %}" href="{{ url_for('main.settings', section='integrations') }}">Integrations</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if section == 'maintenance' %}active{% endif %}" href="{{ url_for('main.settings', section='maintenance') }}">Maintenance</a>
|
||||
</li>
|
||||
@ -136,6 +139,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Navigation</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="require_daily_dashboard_visit" name="require_daily_dashboard_visit" {% if settings.require_daily_dashboard_visit %}checked{% endif %} />
|
||||
<label class="form-check-label" for="require_daily_dashboard_visit">Require dashboard visit on first page view each day</label>
|
||||
</div>
|
||||
<div class="form-text">When enabled, users are redirected to the dashboard on their first page view each day, regardless of which page they try to access.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button type="submit" class="btn btn-primary">Save settings</button>
|
||||
</div>
|
||||
@ -316,6 +330,163 @@
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if section == 'integrations' %}
|
||||
<form method="post" class="mb-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Autotask</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="autotask_enabled" name="autotask_enabled" {% if settings.autotask_enabled %}checked{% endif %} />
|
||||
<label class="form-check-label" for="autotask_enabled">Enable Autotask integration</label>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="autotask_environment" class="form-label">Environment</label>
|
||||
<select class="form-select" id="autotask_environment" name="autotask_environment">
|
||||
<option value="" {% if not settings.autotask_environment %}selected{% endif %}>Select...</option>
|
||||
<option value="sandbox" {% if settings.autotask_environment == 'sandbox' %}selected{% endif %}>Sandbox</option>
|
||||
<option value="production" {% if settings.autotask_environment == 'production' %}selected{% endif %}>Production</option>
|
||||
</select>
|
||||
<div class="form-text">Use Sandbox for testing first.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="autotask_api_username" class="form-label">API Username</label>
|
||||
<input type="text" class="form-control" id="autotask_api_username" name="autotask_api_username" value="{{ settings.autotask_api_username or '' }}" />
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="autotask_api_password" class="form-label">API Password</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="autotask_api_password"
|
||||
name="autotask_api_password"
|
||||
placeholder="{% if has_autotask_password %}******** (stored){% else %}enter password{% endif %}"
|
||||
/>
|
||||
<div class="form-text">Leave empty to keep the existing password.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="autotask_tracking_identifier" class="form-label">Tracking Identifier (Integration Code)</label>
|
||||
<input type="text" class="form-control" id="autotask_tracking_identifier" name="autotask_tracking_identifier" value="{{ settings.autotask_tracking_identifier or '' }}" />
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="autotask_base_url" class="form-label">Backupchecks Base URL</label>
|
||||
<input type="text" class="form-control" id="autotask_base_url" name="autotask_base_url" value="{{ settings.autotask_base_url or '' }}" placeholder="https://backupchecks.example.com" />
|
||||
<div class="form-text">Required later for creating stable links to Job Details pages.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Ticket defaults</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="autotask_default_queue_id" class="form-label">Default Queue</label>
|
||||
<select class="form-select" id="autotask_default_queue_id" name="autotask_default_queue_id">
|
||||
<option value="" {% if not settings.autotask_default_queue_id %}selected{% endif %}>Select...</option>
|
||||
{% for q in autotask_queues %}
|
||||
<option value="{{ q.id }}" {% if settings.autotask_default_queue_id == q.id %}selected{% endif %}>{{ q.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Requires refreshed reference data.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="autotask_default_ticket_source_id" class="form-label">Ticket Source</label>
|
||||
<select class="form-select" id="autotask_default_ticket_source_id" name="autotask_default_ticket_source_id">
|
||||
<option value="" {% if not settings.autotask_default_ticket_source_id %}selected{% endif %}>Select...</option>
|
||||
{% for s in autotask_ticket_sources %}
|
||||
<option value="{{ s.id }}" {% if settings.autotask_default_ticket_source_id == s.id %}selected{% endif %}>{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<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">
|
||||
<option value="" {% if not settings.autotask_priority_warning %}selected{% endif %}>Select...</option>
|
||||
{% for p in autotask_priorities %}
|
||||
<option value="{{ p.id }}" {% if settings.autotask_priority_warning == p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Requires refreshed reference data.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="autotask_priority_error" class="form-label">Priority for Error</label>
|
||||
<select class="form-select" id="autotask_priority_error" name="autotask_priority_error">
|
||||
<option value="" {% if not settings.autotask_priority_error %}selected{% endif %}>Select...</option>
|
||||
{% for p in autotask_priorities %}
|
||||
<option value="{{ p.id }}" {% if settings.autotask_priority_error == p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Requires refreshed reference data.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text mt-2">Priorities are loaded from Autotask to avoid manual ID mistakes.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button type="submit" class="btn btn-primary">Save settings</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Diagnostics & reference data</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<div class="text-muted small">Last reference data sync</div>
|
||||
<div class="fw-semibold">
|
||||
{% if autotask_last_sync_at %}
|
||||
{{ autotask_last_sync_at }}
|
||||
{% else %}
|
||||
never
|
||||
{% endif %}
|
||||
</div>
|
||||
<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>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-md-end">
|
||||
<form method="post" action="{{ url_for('main.settings_autotask_test_connection') }}">
|
||||
<button type="submit" class="btn btn-outline-secondary">Test connection</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('main.settings_autotask_refresh_reference_data') }}">
|
||||
<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, Ticket Statuses, and Priorities from Autotask for dropdown usage.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if section == 'maintenance' %}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-lg-6">
|
||||
|
||||
325
docs/autotask_rest_api.md
Normal file
325
docs/autotask_rest_api.md
Normal file
@ -0,0 +1,325 @@
|
||||
# Autotask REST API – Postman Test Contract
|
||||
|
||||
## Reference Sources
|
||||
|
||||
Primary external reference used during testing:
|
||||
- https://github.com/AutotaskDevelopment/REST-Postman/blob/main/2020.10.29%20-%20Autotask%20Collecction%20-%20Must%20fill%20in%20variables%20on%20collection.postman_collection.json
|
||||
|
||||
This document combines:
|
||||
- Empirically validated Postman test results
|
||||
- Official Autotask Postman collection references (where applicable)
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
This document captures **validated Autotask REST API behaviour** based on hands-on Postman testing.
|
||||
|
||||
This is an **authoritative test contract**:
|
||||
- General-purpose
|
||||
- Product-agnostic
|
||||
- Based on proven results, not assumptions
|
||||
|
||||
If implementation deviates from this document, **the document is correct and the code is wrong**.
|
||||
|
||||
---
|
||||
|
||||
## 0. Base URLs
|
||||
|
||||
### Sandbox
|
||||
https://webservices19.autotask.net/ATServicesRest/V1.0
|
||||
|
||||
### Production
|
||||
https://webservices19.autotask.net/ATServicesRest/V1.0
|
||||
|
||||
Notes:
|
||||
- `ATServicesRest` is case-sensitive
|
||||
- Other casing variants are invalid
|
||||
|
||||
---
|
||||
|
||||
## 0.1 Global Invariants (Do Not Violate)
|
||||
- TicketID (numeric) is the only authoritative identifier for single-ticket operations
|
||||
- TicketNumber is display-only and must be resolved to TicketID first
|
||||
- PATCH is not supported for Tickets
|
||||
- PUT /Tickets is always a **full update**, never partial
|
||||
- Never guess fields or values
|
||||
|
||||
---
|
||||
|
||||
## 1. Ticket Lookup
|
||||
|
||||
### Resolve TicketNumber → TicketID
|
||||
Endpoint:
|
||||
POST /Tickets/query
|
||||
|
||||
Filter:
|
||||
- field: ticketNumber
|
||||
- op: eq
|
||||
- value: <TicketNumber>
|
||||
|
||||
Result:
|
||||
- items[0].id → TicketID
|
||||
|
||||
---
|
||||
|
||||
## 2. Authoritative Ticket Retrieval
|
||||
|
||||
Endpoint:
|
||||
GET /Tickets/{TicketID}
|
||||
|
||||
Always required before any update.
|
||||
|
||||
### 2.1 Response Envelope (Critical)
|
||||
Validated response shape for single-ticket retrieval:
|
||||
- The ticket object is returned under the `item` wrapper
|
||||
|
||||
Example shape:
|
||||
- `issueType` is at `item.issueType`
|
||||
- `subIssueType` is at `item.subIssueType`
|
||||
- `source` is at `item.source`
|
||||
|
||||
Implementation rule:
|
||||
- Always read stabilising fields from `item.*` in the GET response.
|
||||
- Do **not** read these fields from the response root.
|
||||
|
||||
Note:
|
||||
- PUT payloads are **not** wrapped in `item`. They use the plain ticket object fields at the request body root.
|
||||
|
||||
Commonly required stabilising fields:
|
||||
- id
|
||||
- ticketNumber
|
||||
- companyID
|
||||
- queueID
|
||||
- title
|
||||
- priority
|
||||
- status
|
||||
- dueDateTime
|
||||
- ticketCategory
|
||||
- issueType
|
||||
- subIssueType
|
||||
- source
|
||||
- organizationalLevelAssociationID
|
||||
- completedDate
|
||||
- resolvedDateTime
|
||||
- lastTrackedModificationDateTime
|
||||
|
||||
---
|
||||
|
||||
## 3. Ticket Status Picklist (ID → Label)
|
||||
|
||||
Endpoint:
|
||||
GET /Tickets/entityInformation/fields
|
||||
|
||||
Validated behaviour:
|
||||
- `status` is an integer picklist
|
||||
- Picklist values (ID → label) are returned inline
|
||||
- Status IDs and semantics are tenant-dependent
|
||||
|
||||
Do not assume lifecycle meaning based on label alone.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ticket Update Behaviour
|
||||
|
||||
### PATCH
|
||||
- Not supported
|
||||
- Returns error indicating unsupported HTTP method
|
||||
|
||||
### PUT /Tickets
|
||||
|
||||
Validated behaviour:
|
||||
- Full update required
|
||||
- Missing fields cause hard failures
|
||||
- Partial updates are rejected
|
||||
|
||||
Implementation rule:
|
||||
- Always copy required fields from a fresh GET
|
||||
- Change only the intended field(s)
|
||||
|
||||
---
|
||||
|
||||
## 5. Status Semantics (Validated Example)
|
||||
|
||||
Observed in test tenant:
|
||||
|
||||
- Status = 8 (label: "Completed")
|
||||
- Status updates
|
||||
- completedDate = null
|
||||
- resolvedDateTime = null
|
||||
|
||||
- Status = 5 (label: "Complete")
|
||||
- Status updates
|
||||
- completedDate populated
|
||||
- resolvedDateTime populated
|
||||
|
||||
Conclusion:
|
||||
- Resolution timestamps depend on status ID, not label
|
||||
- Validate per tenant before relying on timestamps
|
||||
|
||||
---
|
||||
|
||||
## 6. Time Entry Existence Check (Decisive)
|
||||
|
||||
Entity:
|
||||
TimeEntries
|
||||
|
||||
Endpoint:
|
||||
POST /TimeEntries/query
|
||||
|
||||
Filter:
|
||||
- field: ticketID
|
||||
- op: eq
|
||||
- value: <TicketID>
|
||||
|
||||
Decision:
|
||||
- count = 0 → no time entries
|
||||
- count > 0 → time entries exist
|
||||
|
||||
---
|
||||
|
||||
## 7. Ticket Notes via REST – Capability vs Endpoint Reality
|
||||
|
||||
Although entityInformation reports TicketNote as creatable, **entity-level create does not work**.
|
||||
|
||||
### Non-working endpoints
|
||||
- POST /TicketNotes → 404
|
||||
- POST /TicketNote → 404
|
||||
|
||||
### Working endpoint (only supported method)
|
||||
|
||||
POST /Tickets/{TicketID}/Notes
|
||||
|
||||
Required fields:
|
||||
- Title
|
||||
- Description
|
||||
- NoteType
|
||||
- Publish
|
||||
|
||||
Result:
|
||||
- Note is created and immediately visible
|
||||
|
||||
Query endpoint works:
|
||||
- POST /TicketNotes/query
|
||||
|
||||
---
|
||||
|
||||
## 8. Resolution Field Update (Validated Test)
|
||||
|
||||
### Test scope
|
||||
This section documents the **explicit Postman tests** performed to validate how the `resolution` field can be updated safely.
|
||||
|
||||
### Field characteristics (validated)
|
||||
- name: resolution
|
||||
- dataType: string
|
||||
- max length: 32000
|
||||
- isReadOnly: false
|
||||
|
||||
### Critical constraint (proven)
|
||||
Because **PATCH is not supported** and **PUT /Tickets is a full update**, the resolution field **cannot** be updated in isolation.
|
||||
|
||||
Sending an incomplete payload results in unintended changes to:
|
||||
- classification
|
||||
- routing
|
||||
- status
|
||||
- organizational structure
|
||||
|
||||
### Validated update pattern
|
||||
1. Retrieve current ticket state
|
||||
- GET /Tickets/{TicketID}
|
||||
- Read fields from `item.*` (see section 2.1)
|
||||
|
||||
2. Construct PUT payload by copying current values of stabilising fields
|
||||
(explicitly validated in tests):
|
||||
- id
|
||||
- companyID
|
||||
- queueID
|
||||
- title
|
||||
- priority
|
||||
- status
|
||||
- dueDateTime
|
||||
- ticketCategory
|
||||
- issueType
|
||||
- subIssueType
|
||||
- source
|
||||
- organizationalLevelAssociationID
|
||||
|
||||
3. Change **only** the `resolution` field
|
||||
|
||||
4. Execute update
|
||||
- PUT /Tickets
|
||||
|
||||
### Test result
|
||||
- Resolution text becomes visible in the Autotask UI
|
||||
- No unintended changes occur
|
||||
|
||||
This behaviour was reproduced consistently and is considered authoritative.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ticket Resolution Workflow (Validated Tests)
|
||||
|
||||
This section captures the **end-to-end resolution tests** performed via Postman.
|
||||
|
||||
### Test 1 – Resolution without status change
|
||||
Steps:
|
||||
1. GET /Tickets/{TicketID}
|
||||
2. POST /Tickets/{TicketID}/Notes
|
||||
3. PUT /Tickets (update `resolution` only)
|
||||
|
||||
Result:
|
||||
- Resolution text is updated
|
||||
- Ticket status remains unchanged
|
||||
- completedDate and resolvedDateTime remain null
|
||||
|
||||
Conclusion:
|
||||
- Resolution text alone does **not** resolve a ticket
|
||||
|
||||
---
|
||||
|
||||
### Test 2 – Conditional resolution based on time entries
|
||||
|
||||
Steps:
|
||||
1. GET /Tickets/{TicketID}
|
||||
2. POST /Tickets/{TicketID}/Notes
|
||||
3. PUT /Tickets (update `resolution`)
|
||||
4. POST /TimeEntries/query (filter by ticketID)
|
||||
|
||||
Decision logic (validated):
|
||||
- If **no time entries exist**:
|
||||
- PUT /Tickets with status = 5
|
||||
- completedDate is set
|
||||
- resolvedDateTime is set
|
||||
|
||||
- If **time entries exist**:
|
||||
- Status is NOT changed
|
||||
- Ticket remains open in Autotask
|
||||
|
||||
### Key conclusions
|
||||
- Notes, resolution, and status are independent operations
|
||||
- Status 5 is the only validated status that sets resolution timestamps
|
||||
- Status changes must always be explicit and conditional
|
||||
|
||||
---
|
||||
|
||||
## Non-Negotiable Implementation Rules
|
||||
|
||||
- Always GET before PUT
|
||||
- Never guess stabilising fields
|
||||
- Never use PATCH
|
||||
- Never change status implicitly
|
||||
- Notes and resolution must be explicit
|
||||
|
||||
---
|
||||
|
||||
## 10. API Contract Summary
|
||||
|
||||
Hard rules for code:
|
||||
- Always resolve TicketNumber → TicketID via POST /Tickets/query
|
||||
- Always GET /Tickets/{TicketID} before any update
|
||||
- Never attempt PATCH for Ticket updates
|
||||
- Use PUT /Tickets with a full, stabilised payload
|
||||
- Validate per tenant which status values set completedDate/resolvedDateTime
|
||||
- Check time entries via POST /TimeEntries/query when status decisions depend on them
|
||||
- Create ticket notes only via /Tickets/{TicketID}/Notes
|
||||
|
||||
If code deviates from this document, **the document is correct and the code is wrong**.
|
||||
@ -0,0 +1,412 @@
|
||||
# Backupchecks – Autotask Integration
|
||||
|
||||
## Functional Design
|
||||
|
||||
*Last updated: 2026-01-16*
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope & Goals
|
||||
|
||||
This document describes the **functional design and agreed decisions** for the first phase of the Autotask integration in Backupchecks.
|
||||
|
||||
Goals for phase 1:
|
||||
|
||||
- Allow operators to **manually create Autotask tickets** from Backupchecks.
|
||||
- Ensure **full operator control** over when a ticket is created.
|
||||
- Prevent ticket spam and duplicate tickets.
|
||||
- Maintain clear ownership between Backupchecks and Autotask.
|
||||
- Provide a safe and auditable way to resolve tickets from Backupchecks.
|
||||
|
||||
Out of scope for phase 1:
|
||||
|
||||
- Automatic ticket creation
|
||||
- Automatic ticket closing on success
|
||||
- Issue correlation across multiple runs
|
||||
- Time entry creation or modification
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Principles (Leading)
|
||||
|
||||
These principles apply to all design and implementation choices:
|
||||
|
||||
- Autotask is an **external authoritative system** (PSA).
|
||||
- Backupchecks is a **consumer**, not an owner, of PSA data.
|
||||
- **IDs are leading**, names are display-only.
|
||||
- All PSA mappings are **explicit**, never implicit or automatic.
|
||||
- Operators always retain **manual control**.
|
||||
- Renaming in Autotask must **never break mappings**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Customer ↔ Autotask Company Mapping
|
||||
|
||||
### 3.1 Mapping model
|
||||
|
||||
- Mapping is configured in the **Customers** screen.
|
||||
- Mapping is a **1-to-1 explicit relationship**.
|
||||
- Stored values per customer:
|
||||
- PSA type: autotask
|
||||
- Autotask Company ID (leading)
|
||||
- Autotask Company Name (cached for display)
|
||||
- Last sync timestamp
|
||||
- Mapping status: ok | renamed | missing | invalid
|
||||
|
||||
The Autotask Company ID is the source of truth. The name exists only for UI clarity.
|
||||
|
||||
### 3.2 Name synchronisation
|
||||
|
||||
- If the company name is changed in Autotask:
|
||||
- Backupchecks updates the cached name automatically.
|
||||
- The mapping remains intact.
|
||||
- Backupchecks customer names are independent and never overwritten.
|
||||
|
||||
### 3.3 Failure scenarios
|
||||
|
||||
- Autotask company deleted or inaccessible:
|
||||
- Mapping status becomes invalid.
|
||||
- Ticket creation is blocked.
|
||||
- UI clearly indicates broken mapping.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ticket Creation Model
|
||||
|
||||
### 4.1 Operator-driven creation
|
||||
|
||||
- Tickets are created only via an explicit operator action.
|
||||
- Location: Run Checks page.
|
||||
- Manual ticket number input is removed.
|
||||
- A new action replaces it: Create Autotask ticket.
|
||||
|
||||
### 4.2 One ticket per run
|
||||
|
||||
- Exactly one ticket per run.
|
||||
- A run can never create multiple tickets.
|
||||
- If a ticket exists:
|
||||
- Creation action is replaced by Open ticket.
|
||||
|
||||
### 4.3 Ticket contents (baseline)
|
||||
|
||||
Minimum ticket fields:
|
||||
|
||||
- Subject: [Backupchecks] - - 
|
||||
- Description:
|
||||
- Run date/time
|
||||
- Backup type and job
|
||||
- Affected objects
|
||||
- Error or warning messages
|
||||
- Reference to Backupchecks
|
||||
|
||||
---
|
||||
|
||||
## 5. Ticket State Tracking in Backupchecks
|
||||
|
||||
Per run, Backupchecks stores:
|
||||
|
||||
- Autotask Ticket ID
|
||||
- Autotask Ticket Number
|
||||
- Ticket URL
|
||||
- Created by
|
||||
- Created at timestamp
|
||||
- Last known ticket status (snapshot)
|
||||
|
||||
---
|
||||
|
||||
## 6. Ticket Resolution from Backupchecks
|
||||
|
||||
Backupchecks may resolve a ticket only if:
|
||||
|
||||
- The ticket exists
|
||||
- The ticket is not already closed
|
||||
- No time entries are present
|
||||
|
||||
If time entries exist, the ticket is not closed and an internal system note is added.
|
||||
|
||||
---
|
||||
|
||||
## 7. Backupchecks Settings
|
||||
|
||||
Settings → Extensions & Integrations → Autotask
|
||||
|
||||
Includes:
|
||||
|
||||
- Enable integration (on/off)
|
||||
- Environment
|
||||
- API credentials
|
||||
- Tracking Identifier
|
||||
- Default queue and status
|
||||
- Priority mapping
|
||||
|
||||
### 7.1 Enable / disable behaviour (mandatory)
|
||||
|
||||
Backupchecks must support switching the Autotask integration **on and off at any time**.
|
||||
|
||||
When Autotask integration is **enabled**:
|
||||
- Autotask actions are available (create ticket, resolve ticket, link existing ticket).
|
||||
- Ticket polling/synchronisation (Phase 2) is active.
|
||||
|
||||
When Autotask integration is **disabled**:
|
||||
- Backupchecks falls back to the manual workflow:
|
||||
- Operators can manually enter ticket numbers.
|
||||
- No Autotask API calls are executed.
|
||||
- No polling is performed.
|
||||
- No resolve action is available (as it would require Autotask calls).
|
||||
- Existing stored Autotask references remain visible for audit/history.
|
||||
|
||||
The enable/disable switch must be reversible and must not require data deletion or migration.
|
||||
|
||||
---
|
||||
|
||||
## 8. Roles & Permissions
|
||||
|
||||
- Admin / Operator: create and resolve tickets
|
||||
- Reporter: view-only access
|
||||
|
||||
---
|
||||
|
||||
## 9. Explicit Non-Goals (Phase 1)
|
||||
|
||||
- Automatic ticket creation
|
||||
- Automatic ticket closing
|
||||
- Time entry handling
|
||||
- Multiple tickets per run
|
||||
- PSA-side logic
|
||||
|
||||
---
|
||||
|
||||
## 10. Phase 1 Summary
|
||||
|
||||
Phase 1 delivers controlled, operator-driven PSA integration with a strong focus on auditability and predictability.
|
||||
|
||||
---
|
||||
|
||||
## 11. Phase 2 – Ticket State Synchronisation (PSA-driven)
|
||||
|
||||
This phase introduces **PSA-driven state awareness** for already linked tickets. The goal is to keep Backupchecks aligned with Autotask **without introducing active control or automation**.
|
||||
|
||||
### 11.1 Polling strategy (Run Checks entry point)
|
||||
|
||||
- Polling is executed **only when Autotask integration is enabled**.
|
||||
- When the **Run Checks** page is opened, Backupchecks performs a **targeted poll** to Autotask.
|
||||
|
||||
- When the **Run Checks** page is opened, Backupchecks performs a **targeted poll** to Autotask.
|
||||
- Only tickets that are:
|
||||
- Linked to runs shown on the page, and
|
||||
- Not in a terminal state inside Backupchecks
|
||||
are included.
|
||||
- Tickets already marked as resolved or broken are excluded.
|
||||
|
||||
This prevents unnecessary API calls and limits polling to operator-relevant context.
|
||||
|
||||
### 11.2 Active-ticket-only retrieval
|
||||
|
||||
- Backupchecks only queries tickets that are considered **active** in Autotask.
|
||||
- Completed or closed tickets are not included in the active-ticket query.
|
||||
- This ensures minimal load and avoids repeated retrieval of historical data.
|
||||
|
||||
### 11.3 PSA-driven completion handling
|
||||
|
||||
If Autotask reports a ticket with status **Completed**:
|
||||
|
||||
- The linked run in Backupchecks is automatically marked as **Resolved**.
|
||||
- The resolution is explicitly flagged as:
|
||||
- **Resolved by PSA**
|
||||
- Backupchecks does not add notes or modify the ticket in Autotask.
|
||||
|
||||
UI behaviour:
|
||||
- Operators can clearly see that the resolution originated from the PSA.
|
||||
- A visual indicator highlights that the underlying backup issue may still require verification.
|
||||
|
||||
### 11.4 Operator awareness and follow-up
|
||||
|
||||
When a ticket is resolved by PSA:
|
||||
|
||||
- Backupchecks does not assume the technical issue is resolved.
|
||||
- Operators are expected to:
|
||||
- Review the related run
|
||||
- Decide whether further action is required inside Backupchecks
|
||||
|
||||
No automatic reopening or ticket creation is performed.
|
||||
|
||||
### 11.5 Deleted ticket detection
|
||||
|
||||
If a linked ticket is **deleted in Autotask**:
|
||||
|
||||
- Backupchecks detects this during polling.
|
||||
- The ticket linkage is marked as:
|
||||
- **Deleted in PSA**
|
||||
|
||||
UI behaviour:
|
||||
- A clear warning is shown to the operator.
|
||||
- The historical ticket reference remains visible for audit purposes.
|
||||
- Ticket-related actions are blocked until the operator:
|
||||
- Links a replacement ticket, or
|
||||
- Creates a new Autotask ticket
|
||||
|
||||
### 11.6 Ticket resolution from Backupchecks (operator-driven)
|
||||
|
||||
Phase 2 includes implementation of **manual ticket resolution** from Backupchecks under the already defined Phase 1 rules.
|
||||
|
||||
- Resolution is always an explicit operator action (no automation).
|
||||
- Resolution rules remain leading (see Chapter 6):
|
||||
- Ticket must exist
|
||||
- Ticket must not already be closed
|
||||
- Ticket may only be closed by Backupchecks if **no time entries** exist
|
||||
- If time entries exist, Backupchecks adds an internal system note and leaves the ticket open
|
||||
|
||||
UI behaviour (Run Checks and Job Details):
|
||||
- Show a Resolve ticket action only when a validated Autotask Ticket ID exists.
|
||||
- When resolution succeeds, update the run to Resolved and store:
|
||||
- Resolved timestamp
|
||||
- Resolved by (operator)
|
||||
- Resolution origin: Resolved by Backupchecks
|
||||
|
||||
Important alignment with PSA-driven sync:
|
||||
- If Autotask later reports the ticket as Completed, Backupchecks keeps the run resolved, but the origin remains:
|
||||
- Resolved by Backupchecks
|
||||
- If Autotask reports completion before the operator resolves it, Backupchecks sets:
|
||||
- Resolved by PSA
|
||||
|
||||
### 11.7 Explicit non-goals (Phase 2)
|
||||
|
||||
The following remain explicitly out of scope:
|
||||
|
||||
- Automatic ticket creation
|
||||
- Automatic ticket reopening
|
||||
- Automatic resolution without operator intent
|
||||
- Status pushing from Backupchecks to Autotask (except the explicit Resolve action described above)
|
||||
- Any modification of existing Autotask ticket content (except fixed-format internal system notes used during resolution rules)
|
||||
|
||||
---
|
||||
|
||||
## 12. Future Design Considerations (Post-Phase 2)
|
||||
|
||||
The following sections describe **future design intent only**. They are explicitly **out of scope for Phase 2** and introduce no implementation commitments.
|
||||
|
||||
### 12.1 Ticket lifecycle awareness (read-only intelligence)
|
||||
|
||||
Objective:
|
||||
Provide better insight into the state of linked tickets without adding actions.
|
||||
|
||||
Scope:
|
||||
- Periodic read-only retrieval of ticket status
|
||||
- Display of:
|
||||
- Current status
|
||||
- Queue
|
||||
- Assigned resource (owner)
|
||||
|
||||
Shown in:
|
||||
- Run Checks
|
||||
- Job Details
|
||||
- Tickets / Remarks overview
|
||||
|
||||
Explicitly excluded:
|
||||
- No automatic actions
|
||||
- No status synchronisation back to Autotask
|
||||
|
||||
Value:
|
||||
- Operators need to open Autotask less frequently
|
||||
- Faster visibility into whether a ticket has been picked up
|
||||
|
||||
### 12.2 Operator notes & context enrichment
|
||||
|
||||
Objective:
|
||||
Add contextual information to existing tickets without taking ownership away from the PSA.
|
||||
|
||||
Scope:
|
||||
- Add internal/system notes from Backupchecks
|
||||
- Notes are always written in a fixed format
|
||||
- Notes are added only by explicit operator action
|
||||
|
||||
Rules:
|
||||
- Never overwrite existing ticket content
|
||||
- Never create customer-facing notes
|
||||
- Never generate notes automatically
|
||||
|
||||
Value:
|
||||
- Tickets remain up to date without duplication
|
||||
- Autotask remains the authoritative system
|
||||
|
||||
### 12.3 Controlled assistance (semi-automatic support)
|
||||
|
||||
Objective:
|
||||
Support operator decision-making without introducing automation.
|
||||
|
||||
Examples:
|
||||
- Suggestion: a similar error already has an open ticket
|
||||
- Suggestion: previous run was successful, consider resolving the ticket
|
||||
- Highlight repeated failures without any linked ticket
|
||||
|
||||
Important constraints:
|
||||
- Suggestions only
|
||||
- No automatic ticket creation
|
||||
- No automatic ticket closure
|
||||
|
||||
Value:
|
||||
- Reduced human error
|
||||
- No loss of operator control
|
||||
|
||||
### 12.4 Cross-run correlation (analytical)
|
||||
|
||||
Objective:
|
||||
Provide insight into structural or recurring problems before tickets are created.
|
||||
|
||||
Scope:
|
||||
- Detection of repeated failures across multiple runs
|
||||
- Detection of the same object with the same error over time
|
||||
- Visualisation inside Backupchecks only
|
||||
|
||||
Explicitly excluded:
|
||||
- Ticket bundling
|
||||
- Ticket merging
|
||||
- Any Autotask-side modifications
|
||||
|
||||
Value:
|
||||
- Better decisions prior to ticket creation
|
||||
- Reduced noise in the PSA
|
||||
|
||||
### 12.5 Multi-PSA abstraction (design preparation)
|
||||
|
||||
Objective:
|
||||
Prepare the internal design for future PSA support without implementing it.
|
||||
|
||||
Scope:
|
||||
- PSA-agnostic internal models:
|
||||
- Ticket
|
||||
- Company mapping
|
||||
- Resolution rules
|
||||
- Autotask remains the only concrete implementation
|
||||
|
||||
Why this is documented now:
|
||||
- Prevents Autotask-specific technical debt
|
||||
- Keeps the architecture open for other PSA platforms
|
||||
|
||||
### 12.6 Governance & audit depth
|
||||
|
||||
Objective:
|
||||
Ensure full traceability for MSP and enterprise environments.
|
||||
|
||||
Scope:
|
||||
- Extended audit logging:
|
||||
- Who created, linked or resolved a ticket
|
||||
- When the action occurred
|
||||
- From which run the action originated
|
||||
- Read-only export capabilities
|
||||
- Optional compliance-oriented views
|
||||
|
||||
Value:
|
||||
- MSP and enterprise readiness
|
||||
- Supports internal and external audits
|
||||
|
||||
### 12.7 Explicit non-directions
|
||||
|
||||
The following are intentionally excluded unless a strategic decision is made later:
|
||||
|
||||
- Automatic ticket creation
|
||||
- Automatic ticket handling
|
||||
- Time registration
|
||||
- Mutation of existing ticket content
|
||||
- PSA business logic inside Backupchecks
|
||||
|
||||
@ -0,0 +1,205 @@
|
||||
# Backupchecks – Autotask Integration
|
||||
|
||||
## Implementation Breakdown & Validation Plan
|
||||
|
||||
_Last updated: 2026-01-13_
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose of this document
|
||||
|
||||
This document describes the **logical breakdown of the Autotask integration into implementation phases**.
|
||||
|
||||
It is intended to:
|
||||
- Provide context at the start of each development chat
|
||||
- Keep focus on the **overall goal** while working step by step
|
||||
- Ensure each phase is independently testable and verifiable
|
||||
- Prevent scope creep during implementation
|
||||
|
||||
This document complements:
|
||||
- *Backupchecks – Autotask Integration Functional Design (Phase 1)*
|
||||
|
||||
---
|
||||
|
||||
## 2. Guiding implementation principles
|
||||
|
||||
- Implement in **small, validated steps**
|
||||
- Each phase must be:
|
||||
- Testable in isolation
|
||||
- Reviewable without knowledge of later phases
|
||||
- No UI or workflow assumptions beyond the current phase
|
||||
- Sandbox-first development
|
||||
- No breaking changes without explicit intent
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation phases
|
||||
|
||||
### Phase 1 – Autotask integration foundation
|
||||
|
||||
**Goal:** Establish a reliable, testable Autotask integration layer.
|
||||
|
||||
Scope:
|
||||
- Autotask client/service abstraction
|
||||
- Authentication handling
|
||||
- Tracking Identifier usage
|
||||
- Environment selection (Sandbox / Production)
|
||||
- Test connection functionality
|
||||
- Fetch reference data:
|
||||
- Queues
|
||||
- Ticket Sources
|
||||
|
||||
Out of scope:
|
||||
- UI integration (except minimal test hooks)
|
||||
- Ticket creation
|
||||
- Customer mapping
|
||||
|
||||
Validation criteria:
|
||||
- Successful authentication against Sandbox
|
||||
- Reference data can be retrieved and parsed
|
||||
- Clear error handling for auth and API failures
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 – Settings integration
|
||||
|
||||
**Goal:** Persist and validate Autotask configuration in Backupchecks.
|
||||
|
||||
Scope:
|
||||
- New Settings section:
|
||||
- Extensions & Integrations → Autotask
|
||||
- Store:
|
||||
- Enable/disable toggle
|
||||
- Environment
|
||||
- API credentials
|
||||
- Tracking Identifier
|
||||
- Backupchecks Base URL
|
||||
- Ticket defaults (queue, source, priorities)
|
||||
- Dropdowns populated from live Autotask reference data
|
||||
- Test connection & refresh reference data actions
|
||||
|
||||
Out of scope:
|
||||
- Customer mapping
|
||||
- Ticket creation
|
||||
|
||||
Validation criteria:
|
||||
- Settings can be saved and reloaded
|
||||
- Invalid configurations are blocked
|
||||
- Reference data reflects Autotask configuration
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 – Customer to Autotask company mapping
|
||||
|
||||
**Goal:** Establish stable, ID-based customer mappings.
|
||||
|
||||
Scope:
|
||||
- Customer screen enhancements
|
||||
- Search/select Autotask companies
|
||||
- Store company ID + cached name
|
||||
- Detect and reflect renamed or deleted companies
|
||||
- Mapping status indicators
|
||||
|
||||
Out of scope:
|
||||
- Ticket creation
|
||||
- Run-level logic
|
||||
|
||||
Validation criteria:
|
||||
- Mapping persists correctly
|
||||
- Renaming in Autotask does not break linkage
|
||||
- Deleted companies are detected and reported
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 – Ticket creation from Run Checks
|
||||
|
||||
**Goal:** Allow operators to create Autotask tickets from Backupchecks runs.
|
||||
|
||||
Scope:
|
||||
- “Create Autotask ticket” action
|
||||
- Ticket payload composition rules
|
||||
- Priority mapping (Warning / Error)
|
||||
- Queue, source, status defaults
|
||||
- Job Details page link inclusion
|
||||
- Store ticket ID and number
|
||||
|
||||
Out of scope:
|
||||
- Ticket resolution
|
||||
- Linking existing tickets
|
||||
|
||||
Validation criteria:
|
||||
- Exactly one ticket per run
|
||||
- Tickets contain correct content and links
|
||||
- No duplicate tickets can be created
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 – Ticket resolution flows
|
||||
|
||||
**Goal:** Safely resolve tickets from Backupchecks.
|
||||
|
||||
Scope:
|
||||
- Resolve without time entries:
|
||||
- Internal note
|
||||
- Close ticket
|
||||
- Resolve with time entries:
|
||||
- Internal note only
|
||||
- Ticket remains open
|
||||
- All notes stored as internal/system notes
|
||||
|
||||
Out of scope:
|
||||
- Automatic resolution
|
||||
- Time entry creation
|
||||
|
||||
Validation criteria:
|
||||
- Time entry checks enforced
|
||||
- Correct notes added in all scenarios
|
||||
- Ticket status reflects expected behaviour
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 – Integration disable & compatibility behaviour
|
||||
|
||||
**Goal:** Ensure safe fallback and migration support.
|
||||
|
||||
Scope:
|
||||
- Disable Autotask integration globally
|
||||
- Restore manual ticket number workflow
|
||||
- Optional compatibility mode:
|
||||
- Allow manual ticket number entry while integration enabled
|
||||
- Link existing Autotask tickets to runs
|
||||
|
||||
Validation criteria:
|
||||
- No Autotask API calls when integration is disabled
|
||||
- Existing data remains visible
|
||||
- Operators can safely transition between workflows
|
||||
|
||||
---
|
||||
|
||||
## 4. Usage in development chats
|
||||
|
||||
For each development chat:
|
||||
- Include this document
|
||||
- Include the Functional Design document
|
||||
- Clearly state:
|
||||
- Current phase
|
||||
- Current branch name
|
||||
- Provided source/zip (if applicable)
|
||||
|
||||
This ensures:
|
||||
- Shared context
|
||||
- Focused discussions
|
||||
- Predictable progress
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
This breakdown ensures the Autotask integration is:
|
||||
- Predictable
|
||||
- Auditable
|
||||
- Incrementally delivered
|
||||
- Easy to reason about during implementation
|
||||
|
||||
Each phase builds on the previous one without hidden dependencies.
|
||||
|
||||
432
docs/backupchecks_autotask_integration_phase_2_implementation.md
Normal file
432
docs/backupchecks_autotask_integration_phase_2_implementation.md
Normal file
@ -0,0 +1,432 @@
|
||||
# Backupchecks – Autotask Integration
|
||||
|
||||
## Phase 2 – Implementation Design
|
||||
|
||||
*Document type: Implementation design*
|
||||
*Scope: Phase 2 only*
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document describes the **technical implementation approach** for Phase 2 of the Autotask integration. Phase 2 focuses on **ticket state synchronisation and operator-driven resolution**, based on the approved functional design.
|
||||
|
||||
No future concepts or post–Phase 2 ideas are included in this document.
|
||||
|
||||
---
|
||||
|
||||
## 2. Preconditions
|
||||
|
||||
Phase 2 assumes:
|
||||
|
||||
- Phase 1 is fully implemented and deployed
|
||||
- Autotask integration can be enabled and disabled at runtime
|
||||
- Tickets are already linked to runs via Autotask Ticket ID
|
||||
- Backupchecks is not the authoritative system for ticket state
|
||||
|
||||
---
|
||||
|
||||
## 3. High-level Behaviour Overview
|
||||
|
||||
Phase 2 is implemented in **controlled sub-phases**. Each phase introduces a limited set of actions and ends with an explicit **functional validation moment** before continuing.
|
||||
|
||||
The sub-phases are:
|
||||
|
||||
- Phase 2.1 – Read-only ticket polling
|
||||
- Phase 2.2 – PSA-driven resolution handling
|
||||
- Phase 2.3 – Deleted ticket detection
|
||||
- Phase 2.4 – Manual ticket resolution from Backupchecks
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 2.1 – Read-only Ticket Polling
|
||||
|
||||
### 4.1 Scope
|
||||
|
||||
This phase introduces **read-only polling** of Autotask ticket state. No mutations or resolution logic is applied.
|
||||
|
||||
### 4.2 Trigger point
|
||||
|
||||
Polling is triggered when:
|
||||
|
||||
- Autotask integration is enabled
|
||||
- The **Run Checks** page is opened
|
||||
|
||||
There is no background scheduler or periodic task.
|
||||
|
||||
### 4.3 Polling scope
|
||||
|
||||
Only tickets that meet **all** of the following criteria are included:
|
||||
|
||||
- Ticket is linked to a run visible on the Run Checks page
|
||||
- Run is not already resolved in Backupchecks
|
||||
- Ticket is not marked as deleted or invalid
|
||||
|
||||
### 4.4 Autotask query strategy
|
||||
|
||||
- Query only **active tickets** in Autotask
|
||||
- Ticket ID is always used as the lookup key
|
||||
|
||||
### 4.5 Control moment – Phase 2.1
|
||||
|
||||
Functional validation:
|
||||
|
||||
- Run Checks page loads without delay
|
||||
- Correct tickets are polled
|
||||
- No state changes occur in Backupchecks
|
||||
- No Autotask data is modified
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Polling scope
|
||||
|
||||
Only tickets that meet **all** of the following criteria are included:
|
||||
|
||||
- Ticket is linked to a run visible on the Run Checks page
|
||||
- Run is not already resolved in Backupchecks
|
||||
- Ticket is not marked as deleted or invalid
|
||||
|
||||
This guarantees:
|
||||
|
||||
- Minimal API usage
|
||||
- Operator-context-only data retrieval
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Autotask query strategy
|
||||
|
||||
- Query only **active tickets** in Autotask
|
||||
- Completed / closed tickets are excluded from the active query
|
||||
- Ticket ID is always used as the lookup key
|
||||
|
||||
If an expected ticket is not returned:
|
||||
|
||||
- A follow-up single-ticket lookup is executed
|
||||
- If still not found, the ticket is treated as **deleted in PSA**
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 2.2 – PSA-driven Resolution Handling
|
||||
|
||||
### 5.1 Scope
|
||||
|
||||
This phase adds **state interpretation** on top of read-only polling.
|
||||
|
||||
### 5.2 Completed ticket handling
|
||||
|
||||
If Autotask reports the ticket status as **Completed**:
|
||||
|
||||
- Mark the linked run as **Resolved**
|
||||
- Set resolution origin to:
|
||||
- `Resolved by PSA`
|
||||
- Store resolution timestamp
|
||||
|
||||
No write-back to Autotask is performed.
|
||||
|
||||
### 5.3 Active ticket handling
|
||||
|
||||
If the ticket exists and is active:
|
||||
|
||||
- Update cached ticket status snapshot only
|
||||
- No state change in Backupchecks
|
||||
|
||||
### 5.4 Control moment – Phase 2.2
|
||||
|
||||
Functional validation:
|
||||
|
||||
- PSA-completed tickets resolve runs correctly
|
||||
- Resolution origin is shown correctly
|
||||
- Active tickets do not alter run state
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Completed ticket
|
||||
|
||||
If Autotask reports the ticket status as **Completed**:
|
||||
|
||||
- Mark the linked run as **Resolved**
|
||||
- Set resolution origin to:
|
||||
- `Resolved by PSA`
|
||||
- Store resolution timestamp
|
||||
|
||||
No write-back to Autotask is performed.
|
||||
|
||||
---
|
||||
|
||||
### 5.3 Deleted or missing ticket
|
||||
|
||||
If the ticket cannot be found in Autotask:
|
||||
|
||||
- Mark ticket linkage as:
|
||||
- `Deleted in PSA`
|
||||
- Block ticket-related actions
|
||||
- Preserve historical references (ID, number, URL)
|
||||
|
||||
Operator must explicitly link or create a replacement ticket.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 2.3 – Deleted Ticket Detection
|
||||
|
||||
### 6.1 Scope
|
||||
|
||||
This phase introduces detection of tickets that are removed from Autotask.
|
||||
|
||||
### 6.2 Detection logic
|
||||
|
||||
If a linked ticket is not returned by the active-ticket query:
|
||||
|
||||
- Execute a single-ticket lookup
|
||||
- If still not found:
|
||||
- Mark ticket linkage as `Deleted in PSA`
|
||||
|
||||
### 6.3 Behaviour
|
||||
|
||||
- Ticket actions are blocked
|
||||
- Historical references remain visible
|
||||
- Operator must explicitly relink or recreate a ticket
|
||||
|
||||
### 6.4 Control moment – Phase 2.3
|
||||
|
||||
Functional validation:
|
||||
|
||||
- Deleted tickets are detected reliably
|
||||
- Warning is visible in UI
|
||||
- No silent unlinking occurs
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 2.4 – Manual Ticket Resolution (Backupchecks → Autotask)
|
||||
|
||||
### 7.1 Preconditions
|
||||
|
||||
The Resolve action is available only if:
|
||||
|
||||
- Autotask integration is enabled
|
||||
- A valid Autotask Ticket ID exists
|
||||
- Ticket is not already closed in PSA
|
||||
|
||||
### 7.2 Resolution flow
|
||||
|
||||
1. Operator triggers **Resolve ticket**
|
||||
2. Backupchecks retrieves ticket details from Autotask
|
||||
3. Time entry check is executed
|
||||
|
||||
#### Case A – No time entries
|
||||
|
||||
- Ticket is set to completed in Autotask
|
||||
- Run is marked as **Resolved**
|
||||
- Resolution origin:
|
||||
- `Resolved by Backupchecks`
|
||||
|
||||
#### Case B – Time entries exist
|
||||
|
||||
- Ticket is not closed
|
||||
- Fixed-format internal system note is added
|
||||
- Run remains unresolved
|
||||
|
||||
### 7.3 Control moment – Phase 2.4
|
||||
|
||||
Functional validation:
|
||||
|
||||
- Resolution works only under defined rules
|
||||
- Time entry logic is respected
|
||||
- Resolution origin is persisted correctly
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Resolution flow
|
||||
|
||||
1. Operator triggers **Resolve ticket**
|
||||
2. Backupchecks retrieves ticket details from Autotask
|
||||
3. Time entry check is executed
|
||||
|
||||
#### Case A – No time entries
|
||||
|
||||
- Ticket is set to the configured completed status in Autotask
|
||||
- Run is marked as **Resolved**
|
||||
- Resolution origin:
|
||||
- `Resolved by Backupchecks`
|
||||
|
||||
#### Case B – Time entries exist
|
||||
|
||||
- Ticket is not closed
|
||||
- A fixed-format internal system note is added
|
||||
- Run remains unresolved
|
||||
|
||||
---
|
||||
|
||||
### 6.3 Post-resolution sync alignment
|
||||
|
||||
If a ticket resolved by Backupchecks is later polled as Completed:
|
||||
|
||||
- Backupchecks keeps the existing resolved state
|
||||
- Resolution origin remains:
|
||||
- `Resolved by Backupchecks`
|
||||
|
||||
If Autotask resolves the ticket first:
|
||||
|
||||
- Backupchecks sets:
|
||||
- `Resolved by PSA`
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Model Adjustments
|
||||
|
||||
Phase 2 requires the following additional fields per run:
|
||||
|
||||
- `ticket_last_polled_at`
|
||||
- `ticket_resolution_origin`:
|
||||
- `psa`
|
||||
- `backupchecks`
|
||||
- `ticket_deleted_in_psa` (boolean)
|
||||
|
||||
Existing Phase 1 fields remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 8. UI Behaviour
|
||||
|
||||
### 8.1 Run Checks
|
||||
|
||||
- Visual indicator for linked tickets
|
||||
- Resolution origin badge:
|
||||
- Resolved by PSA
|
||||
- Resolved by Backupchecks
|
||||
- Warning banner for deleted tickets
|
||||
|
||||
---
|
||||
|
||||
### 8.2 Job Details
|
||||
|
||||
- Same indicators as Run Checks
|
||||
- Resolve action visibility follows resolution rules
|
||||
|
||||
---
|
||||
|
||||
## 9. Error Handling & Logging
|
||||
|
||||
- All Autotask API calls are logged with:
|
||||
- Correlation ID
|
||||
- Ticket ID
|
||||
- Run ID
|
||||
|
||||
- Polling failures do not block page rendering
|
||||
- Partial polling failures are tolerated per ticket
|
||||
|
||||
---
|
||||
|
||||
## 10. Explicit Non-Implementation (Phase 2)
|
||||
|
||||
The following are **not implemented**:
|
||||
|
||||
- Background schedulers
|
||||
- Automatic ticket creation
|
||||
- Automatic ticket reopening
|
||||
- Status pushing without operator action
|
||||
- Ticket content mutation beyond fixed system notes
|
||||
|
||||
---
|
||||
|
||||
## 11. Phase-based Implementation Workflow
|
||||
|
||||
For each Phase 2 sub-phase, a **dedicated chat** must be used. This ensures focus, traceability, and controlled implementation.
|
||||
|
||||
The instructions below must be copied **verbatim** when starting a new chat for the corresponding phase.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2.1 – Chat Instructions
|
||||
|
||||
Purpose:
|
||||
Implement **read-only ticket polling** when the Run Checks page is opened.
|
||||
|
||||
Chat instructions:
|
||||
|
||||
- Scope is limited to Phase 2.1 only
|
||||
- No ticket state changes are allowed
|
||||
- No resolve, close, or mutation logic may be added
|
||||
- Only backend polling and UI visibility updates are permitted
|
||||
- All changes must be functionally testable on Run Checks
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Polling executes only when Run Checks is opened
|
||||
- Only relevant active tickets are queried
|
||||
- No Backupchecks run state is modified
|
||||
|
||||
---
|
||||
|
||||
## Phase 2.2 – Chat Instructions
|
||||
|
||||
Purpose:
|
||||
Implement **PSA-driven resolution handling** based on polling results.
|
||||
|
||||
Chat instructions:
|
||||
|
||||
- Phase 2.1 must already be completed and validated
|
||||
- Only Completed ticket handling may be added
|
||||
- Resolution origin must be stored and displayed
|
||||
- No Backupchecks-initiated ticket changes are allowed
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Completed tickets resolve runs correctly
|
||||
- Resolution origin is accurate and persistent
|
||||
- Active tickets remain unchanged
|
||||
|
||||
---
|
||||
|
||||
## Phase 2.3 – Chat Instructions
|
||||
|
||||
Purpose:
|
||||
Implement **deleted ticket detection and operator visibility**.
|
||||
|
||||
Chat instructions:
|
||||
|
||||
- Phase 2.1 and 2.2 must already be completed
|
||||
- Detection must be explicit and non-destructive
|
||||
- Historical ticket references must remain intact
|
||||
- UI must clearly indicate deleted state
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Deleted tickets are reliably detected
|
||||
- Operators are clearly informed
|
||||
- Ticket actions are blocked correctly
|
||||
|
||||
---
|
||||
|
||||
## Phase 2.4 – Chat Instructions
|
||||
|
||||
Purpose:
|
||||
Implement **manual ticket resolution from Backupchecks to Autotask**.
|
||||
|
||||
Chat instructions:
|
||||
|
||||
- All previous Phase 2 sub-phases must be completed
|
||||
- Resolution must be strictly operator-driven
|
||||
- Time entry rules must be enforced exactly
|
||||
- Resolution origin must be stored correctly
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Tickets are resolved only when allowed
|
||||
- Time entry edge cases behave correctly
|
||||
- State remains consistent with PSA polling
|
||||
|
||||
---
|
||||
|
||||
## 12. Phase 2 Completion Criteria
|
||||
|
||||
Phase 2 is considered complete when:
|
||||
|
||||
- All Phase 2 sub-phases have passed their control moments
|
||||
- No cross-phase leakage of logic exists
|
||||
- Enable/disable integration behaviour is respected
|
||||
- Functional behaviour matches the approved design exactly
|
||||
|
||||
---
|
||||
|
||||
End of Phase 2 implementation document.
|
||||
|
||||
137
docs/changelog-claude.md
Normal file
137
docs/changelog-claude.md
Normal file
@ -0,0 +1,137 @@
|
||||
# Changelog - Claude Code
|
||||
|
||||
This file documents all changes made to this project via Claude Code.
|
||||
|
||||
## [2026-02-05]
|
||||
|
||||
### Added
|
||||
- Redesigned changelog system to use Python-based structure instead of Markdown:
|
||||
- Created `app/changelog.py` with structured changelog data (21 versions from v0.1.22 to v0.1.2)
|
||||
- Each version contains: version number, date, summary, and structured sections
|
||||
- Sections include: title, type (feature/improvement/fixed/documentation), and list of changes
|
||||
- Removed Gitea dependency for changelog rendering - now fully self-contained
|
||||
- No external dependencies, faster loading, always available
|
||||
- New changelog.html template with modern design:
|
||||
- Sidebar navigation with all versions for quick jumping between releases
|
||||
- Sticky sidebar that remains visible during scrolling
|
||||
- Bootstrap cards for each version with gradient blue headers
|
||||
- Color-coded type badges for sections:
|
||||
- Green gradient: Feature
|
||||
- Blue gradient: Improvement
|
||||
- Red gradient: Fixed
|
||||
- Purple gradient: Documentation
|
||||
- Responsive design (sidebar hidden on mobile devices)
|
||||
- Summary section with blue left border highlight
|
||||
- Click-to-expand sections with smooth animations
|
||||
- Created `static/css/changelog.css` with comprehensive styling:
|
||||
- Modern gradients for badges and headers
|
||||
- Dark mode support via CSS variables
|
||||
- Hover effects on navigation links and list items
|
||||
- Smooth scrolling to version anchors
|
||||
- Compact spacing optimizations (reduced padding, margins, font sizes)
|
||||
- CSS specificity enhancements with !important flags to override Bootstrap defaults
|
||||
- Added `{% block head %}` to base.html template to allow pages to inject custom CSS
|
||||
|
||||
### Changed
|
||||
- Updated `routes_changelog.py` to load data from `changelog.py` instead of fetching from Gitea
|
||||
- Simplified changelog route - removed markdown parsing and external HTTP requests
|
||||
- Removed dependency on `markdown` library for changelog rendering
|
||||
- Template now receives structured Python data instead of HTML string
|
||||
|
||||
### Fixed
|
||||
- Fixed module import path in routes_changelog.py (changed from `from app.changelog` to `from ..changelog`)
|
||||
- Fixed dictionary key conflict - renamed `items` to `changes` to avoid collision with dict.items() method
|
||||
- Added missing `{% block head %}` in base.html that prevented custom CSS from loading
|
||||
|
||||
### Technical Details
|
||||
- Changelog data structure uses dictionaries with keys: version, date, summary, sections
|
||||
- Sections contain: title, type, subsections (optional), changes
|
||||
- Subsections contain: subtitle, changes
|
||||
- All list items use "changes" key instead of "items" to avoid Python reserved method conflicts
|
||||
- CSS uses !important flags and increased specificity (.changelog-nav .changelog-nav-link) to override Bootstrap
|
||||
- Compact spacing achieved with: 0.15rem padding, 0.15rem margins, 0.85rem/0.7rem font sizes, 1.1/1.0 line heights
|
||||
|
||||
### Added
|
||||
- Autotask customer mapping now auto-searches for similar company names when opening unmapped customers:
|
||||
- Automatically populates search box with customer name
|
||||
- Displays matching Autotask companies as suggestions
|
||||
- Speeds up mapping process by eliminating manual search for most customers
|
||||
- Autotask "Link existing ticket" now supports cross-company ticket search:
|
||||
- Added `query_tickets_by_number()` to search tickets by number across all companies
|
||||
- When searching with a ticket number (e.g., "T20260205.0001"), results include:
|
||||
- Tickets from the customer's company (primary results)
|
||||
- Matching tickets from other companies (for overarching issues)
|
||||
- Enables linking tickets for multi-company infrastructure issues
|
||||
|
||||
### Changed
|
||||
- Autotask resolve confirmation and note messages now correctly indicate ticket closure status:
|
||||
- Frontend confirmation dialog explains conditional closure based on time entries
|
||||
- Backend route checks time entries before creating note and generates dynamic message:
|
||||
- "ticket will be closed in Autotask" when no time entries exist
|
||||
- "ticket remains open in Autotask due to existing time entries" when time entries exist
|
||||
- Route docstring updated to reflect conditional status update behaviour
|
||||
|
||||
### Added
|
||||
- Autotask conditional ticket status update based on time entries (API contract section 9):
|
||||
- `query_time_entries_by_ticket_id()` - Query time entries for a ticket via POST /TimeEntries/query
|
||||
- `update_ticket_resolution_safe()` now checks for time entries and conditionally sets status:
|
||||
- If NO time entries exist: sets status to 5 (Complete) with completedDate and resolvedDateTime
|
||||
- If time entries exist: keeps current status unchanged (ticket remains open)
|
||||
|
||||
### Fixed
|
||||
- Automatic mail import can now be disabled in Settings after being enabled (fixed unchecked checkbox not being processed)
|
||||
- Autotask "Link existing" search box now clears when opening the modal instead of retaining previous search text
|
||||
- Autotask customer mapping search box now clears when opening the edit modal instead of retaining previous search text
|
||||
- Autotask ticket resolution update now correctly preserves exact field values from GET response in PUT payload.
|
||||
The `issueType`, `subIssueType`, and `source` fields are copied with their exact values (including null)
|
||||
from the GET response, as required by Autotask API. Previously these fields were being skipped or modified.
|
||||
|
||||
### Added
|
||||
- Restored Autotask PSA integration from branch `v20260203-13-autotask-resolution-item-wrapper`:
|
||||
- `integrations/autotask/client.py` - Autotask REST API client with full support for:
|
||||
- Zone information discovery
|
||||
- Ticket CRUD operations (create, get, update)
|
||||
- Ticket notes via `/Tickets/{id}/Notes` endpoint
|
||||
- Safe resolution updates preserving stabilizing fields
|
||||
- Query support for companies, tickets, time entries, deleted ticket logs
|
||||
- Reference data retrieval (queues, ticket sources, priorities, statuses)
|
||||
- `ticketing_utils.py` - Utilities for internal ticket management and Autotask linkage
|
||||
- Database migrations for Autotask fields:
|
||||
- `SystemSettings`: Autotask connection settings, defaults, and cached reference data
|
||||
- `Customer`: Autotask company mapping fields
|
||||
- `JobRun`: Autotask ticket linkage and deletion tracking fields
|
||||
- `Ticket`: Resolution origin tracking
|
||||
- Settings UI for Autotask configuration (connection test, reference data sync)
|
||||
- Run Checks integration for Autotask ticket creation
|
||||
- Customers page with Autotask company mapping
|
||||
- Documentation files for Autotask integration design and implementation
|
||||
- Added `docs/autotask_rest_api.md` - Validated Autotask REST API contract based on Postman testing
|
||||
|
||||
## [2026-02-04]
|
||||
|
||||
### Added
|
||||
- `docs/changelog-claude.md` - Changelog file for tracking changes made via Claude Code
|
||||
- Setting to enable/disable daily dashboard redirect requirement (Settings > General > Navigation)
|
||||
- New `require_daily_dashboard_visit` column in `SystemSettings` model
|
||||
- Migration in `migrations.py` to add the column
|
||||
- Toggle in Settings General page under new "Navigation" card
|
||||
- Default value is OFF (disabled) - users can navigate directly to any page
|
||||
|
||||
### Changed
|
||||
- Converted changelog to English (all project documentation must be in English)
|
||||
- Documented branch naming convention and build workflow in Claude memory
|
||||
- Filled README.md with comprehensive project documentation based on source code analysis
|
||||
|
||||
### Performance
|
||||
- Added database indexes migration (`migrations.py`) for frequently queried foreign key columns:
|
||||
- `JobRun`: indexes on `job_id`, `job_id+run_at`, `job_id+reviewed_at`, `mail_message_id`
|
||||
- `MailMessage`: indexes on `job_id`, `location`, `job_id+location`
|
||||
- `MailObject`: index on `mail_message_id`
|
||||
- `TicketScope`: indexes on `ticket_id`, `job_id`
|
||||
- `RemarkScope`: indexes on `remark_id`, `job_id`
|
||||
- Fixed N+1 query in `_recompute_override_flags_for_runs()` - batch loads all jobs instead of per-run queries
|
||||
- Optimized Daily Jobs page with batch queries:
|
||||
- Batch load all today's runs for all jobs in single query
|
||||
- Batch infer weekly schedules for all jobs (was per-job query)
|
||||
- Batch infer monthly schedules for jobs without weekly schedule
|
||||
- Batch load ticket/remark indicators for all jobs
|
||||
@ -1,5 +1,96 @@
|
||||
## v0.1.22
|
||||
|
||||
***
|
||||
This major release introduces comprehensive Autotask PSA integration, enabling seamless ticket management, customer company mapping, and automated ticket lifecycle handling directly from Backupchecks. The integration includes extensive settings configuration, robust API client implementation, intelligent ticket linking across job runs, and conditional ticket status updates based on time entries.
|
||||
|
||||
### Autotask Integration Core Features
|
||||
|
||||
**Settings and Configuration:**
|
||||
- Complete Autotask integration settings in Settings → Integrations
|
||||
- Environment selection (Sandbox/Production) with automatic zone discovery
|
||||
- API authentication with fallback support for different tenant configurations
|
||||
- Tracking identifier (Integration Code) configuration for ticket attribution
|
||||
- Connection testing and diagnostics
|
||||
- Reference data synchronization (queues, sources, priorities, statuses)
|
||||
- Configurable ticket defaults (queue, source, status, priority)
|
||||
- Autotask integration and automatic mail import can now be properly disabled after being enabled (fixed unchecked checkbox processing)
|
||||
|
||||
**Customer Company Mapping:**
|
||||
- Explicit Autotask company mapping for customers using ID-based linkage
|
||||
- Company search with auto-suggestions when opening unmapped customers
|
||||
- Automatically populates search box with customer name and displays matching Autotask companies
|
||||
- Mapping status tracking (ok/renamed/missing/invalid)
|
||||
- Bulk mapping refresh for all customers
|
||||
- Clear search boxes when opening modals for better user experience
|
||||
|
||||
**Ticket Creation and Management:**
|
||||
- Create Autotask tickets directly from Run Checks page
|
||||
- Automatic ticket number assignment and storage
|
||||
- Link existing Autotask tickets to job runs
|
||||
- Cross-company ticket search for overarching infrastructure issues (search by ticket number finds tickets across all companies)
|
||||
- Ticket propagation to all active runs of the same job
|
||||
- Internal ticket registration for legacy compatibility (Tickets, Tickets/Remarks, Job Details)
|
||||
- Real-time ticket status polling and updates
|
||||
- Deleted ticket detection and audit tracking (deletion date/time and deleted-by resource information)
|
||||
|
||||
**Ticket Resolution and Status Management:**
|
||||
- Conditional ticket status updates based on time entries:
|
||||
- Tickets without time entries: automatically closed (status 5 - Complete)
|
||||
- Tickets with time entries: remain open for time tracking continuation
|
||||
- Dynamic confirmation messages indicating closure behavior based on time entry presence
|
||||
- Safe resolution updates preserving stabilizing fields (issueType, subIssueType, source)
|
||||
- Resolution field mirroring from internal ticket notes
|
||||
- Ticket notes created via `/Tickets/{id}/Notes` endpoint with timezone-aware timestamps
|
||||
- Deleted ticket handling with complete audit trail
|
||||
|
||||
**Technical Implementation:**
|
||||
- Full-featured Autotask REST API client (`integrations/autotask/client.py`)
|
||||
- Zone information discovery and endpoint resolution
|
||||
- Robust authentication handling with header-based fallback for sandbox environments
|
||||
- Picklist-based reference data retrieval (queues, sources, priorities, statuses)
|
||||
- Entity metadata parsing with tenant-specific field detection
|
||||
- Database migrations for Autotask linkage fields across SystemSettings, Customer, JobRun, and Ticket models
|
||||
- Ticketing utilities for internal/external ticket synchronization
|
||||
- Comprehensive API contract documentation (`docs/autotask_rest_api.md`)
|
||||
- Functional design living document for integration architecture
|
||||
|
||||
### User Interface Improvements
|
||||
|
||||
- Search boxes now clear automatically when opening modals (Run Checks Link existing, Customer mapping)
|
||||
- Auto-search for similar company names when mapping unmapped customers
|
||||
- Cross-company ticket search when using ticket numbers (e.g., "T20260205.0001")
|
||||
- Dynamic confirmation messages for ticket resolution based on time entries
|
||||
- Improved visibility of Autotask ticket information in Run Checks
|
||||
- Status labels displayed instead of numeric codes in ticket lists
|
||||
- "Deleted in PSA" status display with deletion audit information
|
||||
- "Resolved by PSA (Autotask)" differentiation from Backupchecks-driven resolution
|
||||
|
||||
### Bug Fixes and Stability
|
||||
|
||||
- Fixed Autotask REST API base URL casing (ATServicesRest/V1.0)
|
||||
- Fixed reference data retrieval using correct picklist endpoints
|
||||
- Fixed authentication fallback for sandbox-specific behavior
|
||||
- Fixed company name display from nested API responses
|
||||
- Fixed ticket ID normalization and response unwrapping (itemId handling)
|
||||
- Fixed TicketJobRun linkage for legacy ticket behavior
|
||||
- Fixed unchecked checkbox processing for enable/disable toggles (Autotask integration, automatic mail import)
|
||||
- Fixed ticket resolution updates to preserve exact field values from GET response
|
||||
- Fixed picklist field detection for tenant-specific metadata
|
||||
- Fixed migration stability with idempotent column checks
|
||||
- Fixed settings page crash with local helper functions
|
||||
- Fixed Run Checks modal stacking and Bootstrap 4/5 compatibility
|
||||
- Fixed JavaScript errors (renderModal → renderRun)
|
||||
- Fixed indentation errors preventing application startup
|
||||
- Fixed ticket propagation to ensure all active runs receive ticket linkage
|
||||
- Fixed polling to use read-only operations without state mutation
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added comprehensive Autotask REST API contract documentation (`docs/autotask_rest_api.md`)
|
||||
- Created functional design living document for integration architecture
|
||||
- Documented ticket lifecycle, status management, and time entry considerations
|
||||
- Added changelog tracking for Claude Code changes (`docs/changelog-claude.md`)
|
||||
|
||||
---
|
||||
|
||||
## v0.1.21
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
v0.1.20
|
||||
v0.1.22
|
||||
|
||||
Loading…
Reference in New Issue
Block a user