Add Cove Data Protection full integration

- New models: SystemSettings gets 8 cove_* fields, Job gets
  cove_account_id, JobRun gets source_type and external_id
- Migration migrate_cove_integration() adds all new DB columns and
  a deduplication index on job_runs.external_id
- cove_importer.py: Cove API login, paginated EnumerateAccountStatistics,
  deduplication via external_id, JobRun creation, per-datasource
  run_object_links persistence (Files&Folders, VssMsSql, M365, etc.)
- cove_importer_service.py: background thread, same pattern as
  auto_importer_service, respects cove_import_interval_minutes
- __init__.py: starts cove_importer thread on app startup
- routes_settings.py: Cove form handling (POST), has_cove_password
  variable, new AJAX route /settings/cove/test-connection
- routes_jobs.py: new route /jobs/<id>/set-cove-account,
  cove_enabled passed to job_detail template
- settings.html: Cove card in Integrations tab with AJAX test button
- job_detail.html: Cove Integration card with Account ID input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ivo Oskamp 2026-02-23 10:13:34 +01:00
parent dde2ccbb5d
commit 2f1cc20263
10 changed files with 841 additions and 0 deletions

View File

@ -13,6 +13,7 @@ from .main.routes import main_bp
from .main.routes_documentation import doc_bp
from .migrations import run_migrations
from .auto_importer_service import start_auto_importer
from .cove_importer_service import start_cove_importer
def _get_today_ui_date() -> str:
@ -212,4 +213,7 @@ def create_app():
# Start automatic mail importer background thread
start_auto_importer(app)
# Start Cove Data Protection importer background thread
start_cove_importer(app)
return app

View File

@ -0,0 +1,414 @@
"""Cove Data Protection API importer.
Fetches backup job run data from the Cove (N-able) API and creates
JobRun records in the local database.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
import requests
from sqlalchemy import text
from .database import db
logger = logging.getLogger(__name__)
COVE_DEFAULT_URL = "https://api.backup.management/jsonapi"
# Columns to request from EnumerateAccountStatistics
COVE_COLUMNS = [
"I1", # Account name
"I18", # Account ID
"I8", # Customer / partner name
"I78", # Backup type / product
"D09F00", # Overall last session status
"D09F09", # Last session start timestamp
"D09F15", # Last session end timestamp
"D09F08", # 28-day colorbar
# Datasource-specific status (F00) and last session time (F15)
"D1F00", "D1F15", # Files & Folders
"D10F00", "D10F15", # VssMsSql
"D11F00", "D11F15", # VssSharePoint
"D19F00", "D19F15", # M365 Exchange
"D20F00", "D20F15", # M365 OneDrive
"D5F00", "D5F15", # M365 SharePoint
"D23F00", "D23F15", # M365 Teams
]
# Mapping from Cove status code to Backupchecks status string
STATUS_MAP: dict[int, str] = {
1: "Warning", # Running
2: "Error", # Failed
3: "Error", # Interrupted
5: "Success", # Completed
6: "Error", # Overdue
7: "Warning", # No recent backup
8: "Warning", # Idle
9: "Warning", # Unknown
10: "Error", # Account error
11: "Warning", # Quota exceeded
12: "Warning", # License issue
}
# Datasource label mapping (prefix → human-readable label)
DATASOURCE_LABELS: dict[str, str] = {
"D1": "Files & Folders",
"D10": "VssMsSql",
"D11": "VssSharePoint",
"D19": "M365 Exchange",
"D20": "M365 OneDrive",
"D5": "M365 SharePoint",
"D23": "M365 Teams",
}
class CoveImportError(Exception):
"""Raised when Cove API interaction fails."""
def _cove_login(url: str, username: str, password: str) -> tuple[str, int]:
"""Login to the Cove API and return (visa, partner_id).
Raises CoveImportError on failure.
"""
payload = {
"jsonrpc": "2.0",
"method": "Login",
"id": 1,
"params": {
"Username": username,
"Password": password,
},
}
try:
resp = requests.post(url, json=payload, timeout=30)
resp.raise_for_status()
data = resp.json()
except requests.RequestException as exc:
raise CoveImportError(f"Cove login request failed: {exc}") from exc
except ValueError as exc:
raise CoveImportError(f"Cove login response is not valid JSON: {exc}") from exc
result = data.get("result") or {}
if not result:
error = data.get("error") or {}
raise CoveImportError(f"Cove login failed: {error.get('message', 'unknown error')}")
visa = result.get("Visa") or ""
if not visa:
raise CoveImportError("Cove login succeeded but no Visa token returned")
# Partner/account info may be nested
account_info = result.get("Accounts", [{}])[0] if result.get("Accounts") else {}
partner_id = (
account_info.get("PartnerId")
or result.get("PartnerID")
or result.get("PartnerId")
or 0
)
return visa, int(partner_id)
def _cove_enumerate(
url: str,
visa: str,
partner_id: int,
start: int,
count: int,
) -> list[dict]:
"""Call EnumerateAccountStatistics and return a list of account dicts.
Returns empty list when no more results.
"""
payload = {
"jsonrpc": "2.0",
"method": "EnumerateAccountStatistics",
"id": 2,
"visa": visa,
"params": {
"Query": {
"PartnerId": partner_id,
"DisplayColumns": COVE_COLUMNS,
"StartRecordNumber": start,
"RecordCount": count,
"Columns": COVE_COLUMNS,
}
},
}
try:
resp = requests.post(url, json=payload, timeout=60)
resp.raise_for_status()
data = resp.json()
except requests.RequestException as exc:
raise CoveImportError(f"Cove EnumerateAccountStatistics request failed: {exc}") from exc
except ValueError as exc:
raise CoveImportError(f"Cove EnumerateAccountStatistics response is not valid JSON: {exc}") from exc
result = data.get("result") or {}
if not result:
error = data.get("error") or {}
raise CoveImportError(f"Cove EnumerateAccountStatistics failed: {error.get('message', 'unknown error')}")
return result.get("result", []) or []
def _flatten_settings(account: dict) -> dict:
"""Convert the Settings array in an account dict to a flat key→value dict.
Cove returns settings as a list of {Key, Value} objects.
This flattens them so we can do `flat['D09F00']` instead of iterating.
"""
flat: dict[str, Any] = {}
settings_list = account.get("Settings") or []
if isinstance(settings_list, list):
for item in settings_list:
if isinstance(item, dict):
key = item.get("Key") or item.get("key")
value = item.get("Value") if "Value" in item else item.get("value")
if key is not None:
flat[str(key)] = value
return flat
def _map_status(code: Any) -> str:
"""Map a Cove status code (int) to a Backupchecks status string."""
if code is None:
return "Warning"
try:
return STATUS_MAP.get(int(code), "Warning")
except (ValueError, TypeError):
return "Warning"
def run_cove_import(settings) -> tuple[int, int, int, int]:
"""Fetch Cove account statistics and create/update JobRun records.
Args:
settings: SystemSettings ORM object with cove_* fields.
Returns:
Tuple of (total_accounts, created_runs, skipped_runs, error_count).
Raises:
CoveImportError if the API login fails.
"""
from .models import Job, JobRun # lazy import to avoid circular refs
url = (getattr(settings, "cove_api_url", None) or "").strip() or COVE_DEFAULT_URL
username = (getattr(settings, "cove_api_username", None) or "").strip()
password = (getattr(settings, "cove_api_password", None) or "").strip()
if not username or not password:
raise CoveImportError("Cove API username or password not configured")
visa, partner_id = _cove_login(url, username, password)
# Save partner_id back to settings
if partner_id and partner_id != getattr(settings, "cove_partner_id", None):
settings.cove_partner_id = partner_id
try:
db.session.commit()
except Exception:
db.session.rollback()
total = 0
created = 0
skipped = 0
errors = 0
page_size = 250
start = 0
while True:
try:
accounts = _cove_enumerate(url, visa, partner_id, start, page_size)
except CoveImportError:
raise
except Exception as exc:
raise CoveImportError(f"Unexpected error fetching accounts at offset {start}: {exc}") from exc
if not accounts:
break
for account in accounts:
total += 1
try:
created_this = _process_account(account, settings)
if created_this:
created += 1
else:
skipped += 1
except Exception as exc:
errors += 1
logger.warning("Cove import: error processing account: %s", exc)
try:
db.session.rollback()
except Exception:
pass
if len(accounts) < page_size:
break
start += page_size
# Update last import timestamp
settings.cove_last_import_at = datetime.utcnow()
try:
db.session.commit()
except Exception:
db.session.rollback()
return total, created, skipped, errors
def _process_account(account: dict, settings) -> bool:
"""Process a single Cove account and create a JobRun if needed.
Returns True if a new JobRun was created, False if skipped (duplicate or no job match).
"""
from .models import Job, JobRun
from sqlalchemy import text
flat = _flatten_settings(account)
# AccountId try both the top-level field and Settings
account_id = account.get("AccountId") or account.get("AccountID") or flat.get("I18")
if not account_id:
return False
try:
account_id = int(account_id)
except (ValueError, TypeError):
return False
# Last session end timestamp (D09F15) used as run_at
run_ts_raw = flat.get("D09F15")
if not run_ts_raw:
# No last run timestamp skip this account
return False
try:
run_ts = int(run_ts_raw)
except (ValueError, TypeError):
return False
if run_ts <= 0:
return False
# Deduplication key
external_id = f"cove-{account_id}-{run_ts}"
# Check for duplicate
existing = JobRun.query.filter_by(external_id=external_id).first()
if existing:
return False
# Find corresponding Job
job = Job.query.filter_by(cove_account_id=account_id).first()
if not job:
return False
# Convert Unix timestamp to datetime (UTC)
run_at = datetime.fromtimestamp(run_ts, tz=timezone.utc).replace(tzinfo=None)
# Map overall status
status_code = flat.get("D09F00")
status = _map_status(status_code)
# Create JobRun
run = JobRun(
job_id=job.id,
mail_message_id=None,
run_at=run_at,
status=status,
remark=None,
missed=False,
override_applied=False,
source_type="cove_api",
external_id=external_id,
)
db.session.add(run)
db.session.flush() # get run.id
# Persist per-datasource objects
customer_id = job.customer_id
if customer_id:
_persist_datasource_objects(flat, customer_id, job.id, run.id, run_at)
db.session.commit()
return True
def _persist_datasource_objects(
flat: dict,
customer_id: int,
job_id: int,
run_id: int,
observed_at: datetime,
) -> None:
"""Create run_object_links for each active datasource found in the account stats."""
engine = db.get_engine()
with engine.begin() as conn:
for ds_prefix, ds_label in DATASOURCE_LABELS.items():
status_key = f"{ds_prefix}F00"
status_code = flat.get(status_key)
if status_code is None:
continue
status = _map_status(status_code)
# Upsert customer_objects
customer_object_id = conn.execute(
text(
"""
INSERT INTO customer_objects (customer_id, object_name, object_type, first_seen_at, last_seen_at)
VALUES (:customer_id, :object_name, :object_type, NOW(), NOW())
ON CONFLICT (customer_id, object_name)
DO UPDATE SET
last_seen_at = NOW(),
object_type = COALESCE(EXCLUDED.object_type, customer_objects.object_type)
RETURNING id
"""
),
{
"customer_id": customer_id,
"object_name": ds_label,
"object_type": "cove_datasource",
},
).scalar()
# Upsert job_object_links
conn.execute(
text(
"""
INSERT INTO job_object_links (job_id, customer_object_id, first_seen_at, last_seen_at)
VALUES (:job_id, :customer_object_id, NOW(), NOW())
ON CONFLICT (job_id, customer_object_id)
DO UPDATE SET last_seen_at = NOW()
"""
),
{"job_id": job_id, "customer_object_id": customer_object_id},
)
# Upsert run_object_links
conn.execute(
text(
"""
INSERT INTO run_object_links (run_id, customer_object_id, status, error_message, observed_at)
VALUES (:run_id, :customer_object_id, :status, NULL, :observed_at)
ON CONFLICT (run_id, customer_object_id)
DO UPDATE SET
status = EXCLUDED.status,
observed_at = EXCLUDED.observed_at
"""
),
{
"run_id": run_id,
"customer_object_id": customer_object_id,
"status": status,
"observed_at": observed_at,
},
)

View File

@ -0,0 +1,101 @@
"""Cove Data Protection importer background service.
Runs a background thread that periodically fetches backup job run data
from the Cove API and creates JobRun records in the local database.
"""
from __future__ import annotations
import threading
import time
from datetime import datetime
from .admin_logging import log_admin_event
from .cove_importer import CoveImportError, run_cove_import
from .models import SystemSettings
_COVE_IMPORTER_THREAD_NAME = "cove_importer"
def start_cove_importer(app) -> None:
"""Start the Cove importer background thread.
The thread checks settings on every loop and only runs imports when
enabled and the configured interval has elapsed.
"""
# Avoid starting multiple threads if create_app() is called more than once.
if any(t.name == _COVE_IMPORTER_THREAD_NAME for t in threading.enumerate()):
return
def _worker() -> None:
last_run_at: datetime | None = None
while True:
try:
with app.app_context():
settings = SystemSettings.query.first()
if settings is None:
time.sleep(10)
continue
enabled = bool(getattr(settings, "cove_import_enabled", False))
try:
interval_minutes = int(getattr(settings, "cove_import_interval_minutes", 30) or 30)
except (TypeError, ValueError):
interval_minutes = 30
if interval_minutes < 1:
interval_minutes = 1
now = datetime.utcnow()
due = False
if enabled:
if last_run_at is None:
due = True
else:
due = (now - last_run_at).total_seconds() >= (interval_minutes * 60)
if not due:
time.sleep(5)
continue
try:
total, created, skipped, errors = run_cove_import(settings)
except CoveImportError as exc:
log_admin_event(
"cove_import_error",
f"Cove import failed: {exc}",
)
last_run_at = now
time.sleep(5)
continue
except Exception as exc:
log_admin_event(
"cove_import_error",
f"Unexpected error during Cove import: {exc}",
)
last_run_at = now
time.sleep(5)
continue
log_admin_event(
"cove_import",
f"Cove import finished. accounts={total}, created={created}, skipped={skipped}, errors={errors}",
)
last_run_at = now
except Exception:
# Never let the thread die.
try:
with app.app_context():
log_admin_event(
"cove_import_error",
"Cove importer thread recovered from an unexpected exception.",
)
except Exception:
pass
time.sleep(5)
t = threading.Thread(target=_worker, name=_COVE_IMPORTER_THREAD_NAME, daemon=True)
t.start()

View File

@ -187,6 +187,35 @@ def unarchive_job(job_id: int):
return redirect(url_for("main.archived_jobs"))
@main_bp.route("/jobs/<int:job_id>/set-cove-account", methods=["POST"])
@login_required
@roles_required("admin", "operator")
def job_set_cove_account(job_id: int):
"""Save or clear the Cove Account ID for this job."""
job = Job.query.get_or_404(job_id)
account_id_raw = (request.form.get("cove_account_id") or "").strip()
if account_id_raw:
try:
job.cove_account_id = int(account_id_raw)
except (ValueError, TypeError):
flash("Invalid Cove Account ID must be a number.", "warning")
return redirect(url_for("main.job_detail", job_id=job_id))
else:
job.cove_account_id = None
db.session.commit()
try:
log_admin_event(
"job_cove_account_set",
f"Set Cove Account ID for job {job.id} to {job.cove_account_id!r}",
details=f"job_name={job.job_name}",
)
except Exception:
pass
flash("Cove Account ID saved.", "success")
return redirect(url_for("main.job_detail", job_id=job_id))
@main_bp.route("/jobs/<int:job_id>")
@login_required
@roles_required("admin", "operator", "viewer")
@ -491,6 +520,11 @@ def job_detail(job_id: int):
if job.customer_id:
customer = Customer.query.get(job.customer_id)
# Load system settings for Cove integration display
from ..models import SystemSettings as _SystemSettings
_settings = _SystemSettings.query.first()
cove_enabled = bool(getattr(_settings, "cove_enabled", False)) if _settings else False
return render_template(
"main/job_detail.html",
job=job,
@ -507,6 +541,7 @@ def job_detail(job_id: int):
has_prev=has_prev,
has_next=has_next,
can_manage_jobs=can_manage_jobs,
cove_enabled=cove_enabled,
)

View File

@ -786,6 +786,7 @@ def settings():
if request.method == "POST":
autotask_form_touched = any(str(k).startswith("autotask_") for k in (request.form or {}).keys())
cove_form_touched = any(str(k).startswith("cove_") 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())
general_form_touched = "ui_timezone" in request.form
mail_form_touched = any(k in request.form for k in ["graph_tenant_id", "graph_client_id", "graph_mailbox", "incoming_folder", "processed_folder"])
@ -908,6 +909,31 @@ def settings():
except (ValueError, TypeError):
pass
# Cove Data Protection integration
if cove_form_touched:
settings.cove_enabled = bool(request.form.get("cove_enabled"))
settings.cove_import_enabled = bool(request.form.get("cove_import_enabled"))
if "cove_api_url" in request.form:
settings.cove_api_url = (request.form.get("cove_api_url") or "").strip() or None
if "cove_api_username" in request.form:
settings.cove_api_username = (request.form.get("cove_api_username") or "").strip() or None
if "cove_api_password" in request.form:
pw = (request.form.get("cove_api_password") or "").strip()
if pw:
settings.cove_api_password = pw
if "cove_import_interval_minutes" in request.form:
try:
interval = int(request.form.get("cove_import_interval_minutes") or 30)
if interval < 1:
interval = 1
settings.cove_import_interval_minutes = interval
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()
@ -1119,6 +1145,7 @@ def settings():
has_client_secret = bool(settings.graph_client_secret)
has_autotask_password = bool(getattr(settings, "autotask_api_password", None))
has_cove_password = bool(getattr(settings, "cove_api_password", None))
# Common UI timezones (IANA names)
tz_options = [
@ -1244,6 +1271,7 @@ def settings():
free_disk_warning=free_disk_warning,
has_client_secret=has_client_secret,
has_autotask_password=has_autotask_password,
has_cove_password=has_cove_password,
tz_options=tz_options,
users=users,
admin_users_count=admin_users_count,
@ -1258,6 +1286,44 @@ def settings():
)
@main_bp.route("/settings/cove/test-connection", methods=["POST"])
@login_required
@roles_required("admin")
def settings_cove_test_connection():
"""Test the Cove Data Protection API connection and return JSON result."""
from flask import jsonify
from ..cove_importer import CoveImportError, _cove_login, COVE_DEFAULT_URL
settings = _get_or_create_settings()
username = (getattr(settings, "cove_api_username", None) or "").strip()
password = (getattr(settings, "cove_api_password", None) or "").strip()
url = (getattr(settings, "cove_api_url", None) or "").strip() or COVE_DEFAULT_URL
if not username or not password:
return jsonify({"ok": False, "message": "Cove API username and password must be saved first."})
try:
visa, partner_id = _cove_login(url, username, password)
# Store the partner_id
settings.cove_partner_id = partner_id
db.session.commit()
_log_admin_event(
"cove_test_connection",
f"Cove connection test succeeded. Partner ID: {partner_id}",
)
return jsonify({
"ok": True,
"partner_id": partner_id,
"message": f"Connected Partner ID: {partner_id}",
})
except CoveImportError as exc:
db.session.rollback()
return jsonify({"ok": False, "message": str(exc)})
except Exception as exc:
db.session.rollback()
return jsonify({"ok": False, "message": f"Unexpected error: {exc}"})
@main_bp.route("/settings/news/create", methods=["POST"])
@login_required

View File

@ -1070,6 +1070,70 @@ def migrate_rename_admin_logs_to_audit_logs() -> None:
print("[migrations] audit_logs table will be created by db.create_all()")
def migrate_cove_integration() -> None:
"""Add Cove Data Protection integration columns if missing.
Adds to system_settings:
- cove_enabled (BOOLEAN NOT NULL DEFAULT FALSE)
- cove_api_url (VARCHAR(255) NULL)
- cove_api_username (VARCHAR(255) NULL)
- cove_api_password (VARCHAR(255) NULL)
- cove_import_enabled (BOOLEAN NOT NULL DEFAULT FALSE)
- cove_import_interval_minutes (INTEGER NOT NULL DEFAULT 30)
- cove_partner_id (INTEGER NULL)
- cove_last_import_at (TIMESTAMP NULL)
Adds to jobs:
- cove_account_id (INTEGER NULL)
Adds to job_runs:
- source_type (VARCHAR(20) NULL)
- external_id (VARCHAR(100) NULL)
"""
try:
engine = db.get_engine()
except Exception as exc:
print(f"[migrations] Could not get engine for Cove integration migration: {exc}")
return
try:
with engine.begin() as conn:
# system_settings columns
ss_columns = [
("cove_enabled", "BOOLEAN NOT NULL DEFAULT FALSE"),
("cove_api_url", "VARCHAR(255) NULL"),
("cove_api_username", "VARCHAR(255) NULL"),
("cove_api_password", "VARCHAR(255) NULL"),
("cove_import_enabled", "BOOLEAN NOT NULL DEFAULT FALSE"),
("cove_import_interval_minutes", "INTEGER NOT NULL DEFAULT 30"),
("cove_partner_id", "INTEGER NULL"),
("cove_last_import_at", "TIMESTAMP NULL"),
]
for column, ddl in ss_columns:
if _column_exists_on_conn(conn, "system_settings", column):
continue
conn.execute(text(f'ALTER TABLE "system_settings" ADD COLUMN {column} {ddl}'))
# jobs column
if not _column_exists_on_conn(conn, "jobs", "cove_account_id"):
conn.execute(text('ALTER TABLE "jobs" ADD COLUMN cove_account_id INTEGER NULL'))
# job_runs columns
if not _column_exists_on_conn(conn, "job_runs", "source_type"):
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN source_type VARCHAR(20) NULL'))
if not _column_exists_on_conn(conn, "job_runs", "external_id"):
conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN external_id VARCHAR(100) NULL'))
# Index for deduplication lookups
conn.execute(text(
'CREATE INDEX IF NOT EXISTS idx_job_runs_external_id ON "job_runs" (external_id)'
))
print("[migrations] migrate_cove_integration completed.")
except Exception as exc:
print(f"[migrations] Failed to migrate Cove integration columns: {exc}")
def run_migrations() -> None:
print("[migrations] Starting migrations...")
migrate_add_username_to_users()
@ -1112,6 +1176,7 @@ def run_migrations() -> None:
migrate_performance_indexes()
migrate_system_settings_require_daily_dashboard_visit()
migrate_rename_admin_logs_to_audit_logs()
migrate_cove_integration()
print("[migrations] All migrations completed.")

View File

@ -117,6 +117,16 @@ class SystemSettings(db.Model):
# this is not a production environment.
is_sandbox_environment = db.Column(db.Boolean, nullable=False, default=False)
# Cove Data Protection integration settings
cove_enabled = db.Column(db.Boolean, nullable=False, default=False)
cove_api_url = db.Column(db.String(255), nullable=True) # default: https://api.backup.management/jsonapi
cove_api_username = db.Column(db.String(255), nullable=True)
cove_api_password = db.Column(db.String(255), nullable=True)
cove_import_enabled = db.Column(db.Boolean, nullable=False, default=False)
cove_import_interval_minutes = db.Column(db.Integer, nullable=False, default=30)
cove_partner_id = db.Column(db.Integer, nullable=True) # stored after successful login
cove_last_import_at = db.Column(db.DateTime, nullable=True)
# Autotask integration settings
autotask_enabled = db.Column(db.Boolean, nullable=False, default=False)
autotask_environment = db.Column(db.String(32), nullable=True) # sandbox | production
@ -242,6 +252,9 @@ class Job(db.Model):
auto_approve = db.Column(db.Boolean, nullable=False, default=True)
active = db.Column(db.Boolean, nullable=False, default=True)
# Cove Data Protection integration
cove_account_id = db.Column(db.Integer, nullable=True) # Cove AccountId mapping
# Archived jobs are excluded from Daily Jobs and Run Checks.
# JobRuns remain in the database and are still included in reporting.
archived = db.Column(db.Boolean, nullable=False, default=False)
@ -290,6 +303,10 @@ 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)
# Import source tracking
source_type = db.Column(db.String(20), nullable=True) # NULL = email (backwards compat), "cove_api"
external_id = db.Column(db.String(100), nullable=True) # e.g. "cove-{account_id}-{run_ts}" for deduplication
# 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)

View File

@ -59,6 +59,34 @@
</div>
{% endif %}
{% if cove_enabled and can_manage_jobs %}
<div class="card mb-3">
<div class="card-header">Cove Integration</div>
<div class="card-body">
<form method="post" action="{{ url_for('main.job_set_cove_account', job_id=job.id) }}" class="row g-2 align-items-end mb-0">
<div class="col-auto">
<label for="cove_account_id" class="form-label mb-1">Cove Account ID</label>
<input type="number" class="form-control form-control-sm" id="cove_account_id" name="cove_account_id"
value="{{ job.cove_account_id or '' }}" placeholder="e.g. 4504627" style="width: 180px;" />
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-primary">Save</button>
{% if job.cove_account_id %}
<button type="submit" name="cove_account_id" value="" class="btn btn-sm btn-outline-secondary ms-1">Clear</button>
{% endif %}
</div>
<div class="col-auto text-muted small">
{% if job.cove_account_id %}
Linked to Cove account <strong>{{ job.cove_account_id }}</strong>
{% else %}
Not linked to a Cove account runs will not be imported automatically.
{% endif %}
</div>
</form>
</div>
</div>
{% endif %}
<h3 class="mt-4 mb-3">Job history</h3>
<div class="table-responsive">

View File

@ -504,6 +504,106 @@
</div>
{% endif %}
{% if section == 'integrations' %}
<form method="post" class="mb-4" id="cove-settings-form">
<div class="card mb-3">
<div class="card-header">Cove Data Protection (N-able)</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="cove_enabled" name="cove_enabled" {% if settings.cove_enabled %}checked{% endif %} />
<label class="form-check-label" for="cove_enabled">Enable Cove integration</label>
</div>
<div class="row g-3">
<div class="col-md-12">
<label for="cove_api_url" class="form-label">API URL</label>
<input type="url" class="form-control" id="cove_api_url" name="cove_api_url"
value="{{ settings.cove_api_url or '' }}"
placeholder="https://api.backup.management/jsonapi" />
<div class="form-text">Leave empty to use the default Cove API endpoint.</div>
</div>
<div class="col-md-6">
<label for="cove_api_username" class="form-label">API Username <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="cove_api_username" name="cove_api_username"
value="{{ settings.cove_api_username or '' }}" />
</div>
<div class="col-md-6">
<label for="cove_api_password" class="form-label">API Password {% if not has_cove_password %}<span class="text-danger">*</span>{% endif %}</label>
<input type="password" class="form-control" id="cove_api_password" name="cove_api_password"
placeholder="{% if has_cove_password %}******** (stored){% else %}enter password{% endif %}" />
<div class="form-text">Leave empty to keep the existing password.</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="cove_import_enabled" name="cove_import_enabled" {% if settings.cove_import_enabled %}checked{% endif %} />
<label class="form-check-label" for="cove_import_enabled">Enable automatic import</label>
</div>
</div>
<div class="col-md-6">
<label for="cove_import_interval_minutes" class="form-label">Import interval (minutes)</label>
<input type="number" class="form-control" id="cove_import_interval_minutes" name="cove_import_interval_minutes"
value="{{ settings.cove_import_interval_minutes or 30 }}" min="1" max="1440" />
<div class="form-text">How often (in minutes) to fetch new data from the Cove API.</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<div id="cove-test-result" class="small"></div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" id="cove-test-btn">Test connection</button>
<button type="submit" class="btn btn-primary">Save Cove Settings</button>
</div>
</div>
{% if settings.cove_partner_id %}
<div class="mt-2 text-muted small">
Connected Partner ID: <strong>{{ settings.cove_partner_id }}</strong>
{% if settings.cove_last_import_at %}
&nbsp;·&nbsp; Last import: {{ settings.cove_last_import_at|local_datetime }}
{% endif %}
</div>
{% endif %}
</div>
</div>
</form>
<script>
(function () {
var btn = document.getElementById('cove-test-btn');
var resultDiv = document.getElementById('cove-test-result');
if (!btn) return;
btn.addEventListener('click', function () {
btn.disabled = true;
resultDiv.textContent = 'Testing…';
resultDiv.className = 'small text-muted';
fetch('{{ url_for("main.settings_cove_test_connection") }}', {
method: 'POST',
headers: { 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]') ? document.querySelector('meta[name="csrf-token"]').content : '' },
credentials: 'same-origin',
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.ok) {
resultDiv.textContent = data.message;
resultDiv.className = 'small text-success';
} else {
resultDiv.textContent = data.message;
resultDiv.className = 'small text-danger';
}
})
.catch(function (err) {
resultDiv.textContent = 'Request failed: ' + err;
resultDiv.className = 'small text-danger';
})
.finally(function () { btn.disabled = false; });
});
})();
</script>
{% endif %}
{% if section == 'maintenance' %}
<div class="row g-3 mb-4">

View File

@ -5,6 +5,17 @@ This file documents all changes made to this project via Claude Code.
## [2026-02-23]
### Added
- Cove Data Protection full integration into Backupchecks:
- `app/cove_importer.py` Cove API client: login, paginated EnumerateAccountStatistics, status mapping, deduplication, per-datasource object persistence
- `app/cove_importer_service.py` background thread that polls Cove API on configurable interval
- `SystemSettings` model: 8 new Cove fields (`cove_enabled`, `cove_api_url`, `cove_api_username`, `cove_api_password`, `cove_import_enabled`, `cove_import_interval_minutes`, `cove_partner_id`, `cove_last_import_at`)
- `Job` model: `cove_account_id` column to link a job to a Cove account
- `JobRun` model: `source_type` (NULL = email, "cove_api") and `external_id` (deduplication key) columns
- DB migration `migrate_cove_integration()` for all new columns + deduplication index
- Settings > Integrations tab: new Cove section with enable toggle, API URL/username/password, import interval, and Test Connection button (AJAX → JSON response with partner ID)
- Job Detail page: Cove Integration card showing Account ID input (only when `cove_enabled`)
- Route `POST /settings/cove/test-connection` verifies Cove credentials and stores partner ID
- Route `POST /jobs/<id>/set-cove-account` saves or clears Cove Account ID on a job
- `cove_api_test.py` standalone Python test script to verify Cove Data Protection API column codes
- Tests D9Fxx (Total), D10Fxx (VssMsSql), D11Fxx (VssSharePoint), and D1Fxx (Files&Folders)
- Displays backup status (F00), timestamps (F09/F15/F18), error counts (F06) per account