Features: - Smart Overrides Phase 1: create overrides directly from Run Checks via the "Apply override for future runs?" follow-up dialog after Mark as Success (scope + duration choices, audit-logged). - Cove workstation offline handling: skip schedule-based missed-runs for Cove workstations (always on) and add an optional colorbar-based offline-detection toggle in Settings -> Integrations -> Cove (cove_offline_detection_enabled, cove_workstation_warning_days, cove_workstation_error_days). Synthetic offline runs use a stable external_id so they escalate in place and clear once activity resumes. - Settings -> Maintenance: Generate test run card for exercising the Smart Override flow. - Restored Mark as Success button in the Run Checks modal footer. Changes: - Run Checks Cove same-day suppression: hide repeat Cove runs after the first complete success run on the same local day. - Inbox excludes mail messages linked to archived jobs. - Run Checks / Search overview now applies Customer.active filter. - In-app documentation refreshed across getting-started, users, mail-import, integrations (Cove), settings, backup-review, customers-jobs and autotask sections. Tooling: - Adopted the shared docker-build-and-push script. Modes are now t / r; release version is read from docs/changelog.md; the script no longer performs git operations. Removed obsolete version.txt and .last-branch. Renames: - docs/technical-notes-codex.md -> docs/TECHNICAL.md - docs/changelog-claude.md -> docs/changelog-develop.md Migrations: - migrate_cove_offline_detection (3 columns on system_settings).
849 lines
36 KiB
Python
849 lines
36 KiB
Python
from datetime import datetime
|
||
from flask_login import UserMixin
|
||
from flask import session, has_request_context
|
||
from werkzeug.security import generate_password_hash, check_password_hash
|
||
|
||
from .database import db
|
||
|
||
|
||
class User(db.Model, UserMixin):
|
||
__tablename__ = "users"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
# username is the primary login identifier
|
||
username = db.Column(db.String(255), unique=True, nullable=False)
|
||
|
||
# email is kept for future use and may be NULL
|
||
email = db.Column(db.String(255), nullable=True)
|
||
|
||
password_hash = db.Column(db.String(255), nullable=False)
|
||
role = db.Column(db.String(50), nullable=False, default="viewer")
|
||
# UI theme preference: 'auto' (follow OS), 'light', 'dark'
|
||
theme_preference = db.Column(db.String(16), nullable=False, default="auto")
|
||
# Run Checks user preferences
|
||
run_checks_sort_mode = db.Column(db.String(32), nullable=False, default="customer")
|
||
run_checks_filter_statuses = db.Column(db.Text, nullable=False, default="")
|
||
run_checks_filter_has_ticket = db.Column(db.Boolean, nullable=False, default=False)
|
||
run_checks_filter_has_remark = db.Column(db.Boolean, nullable=False, default=False)
|
||
run_checks_filter_q = db.Column(db.String(255), nullable=True)
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
def set_password(self, password: str) -> None:
|
||
self.password_hash = generate_password_hash(password)
|
||
|
||
def check_password(self, password: str) -> bool:
|
||
return check_password_hash(self.password_hash, password)
|
||
|
||
@property
|
||
def roles(self) -> list[str]:
|
||
"""Return all assigned roles.
|
||
|
||
The database stores roles as a comma-separated string for backwards
|
||
compatibility with older schemas.
|
||
"""
|
||
raw = (self.role or "").strip()
|
||
if not raw:
|
||
return ["viewer"]
|
||
parts = [p.strip() for p in raw.split(",")]
|
||
roles = [p for p in parts if p]
|
||
return roles or ["viewer"]
|
||
|
||
@property
|
||
def active_role(self) -> str:
|
||
"""Return the currently active role for this user.
|
||
|
||
When a request context exists, the active role is stored in the session.
|
||
If the stored role is not assigned to the user, it falls back to the
|
||
first assigned role.
|
||
"""
|
||
default_role = self.roles[0]
|
||
if not has_request_context():
|
||
return default_role
|
||
selected = (session.get("active_role") or "").strip()
|
||
if selected and selected in self.roles:
|
||
return selected
|
||
session["active_role"] = default_role
|
||
return default_role
|
||
|
||
def set_active_role(self, role: str) -> None:
|
||
"""Set the active role in the current session (if possible)."""
|
||
if not has_request_context():
|
||
return
|
||
role = (role or "").strip()
|
||
if role and role in self.roles:
|
||
session["active_role"] = role
|
||
else:
|
||
session["active_role"] = self.roles[0]
|
||
|
||
|
||
@property
|
||
def is_admin(self) -> bool:
|
||
return self.active_role == "admin"
|
||
|
||
|
||
class SystemSettings(db.Model):
|
||
__tablename__ = "system_settings"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
# Graph / mail settings
|
||
graph_tenant_id = db.Column(db.String(255), nullable=True)
|
||
graph_client_id = db.Column(db.String(255), nullable=True)
|
||
graph_client_secret = db.Column(db.String(255), nullable=True)
|
||
graph_mailbox = db.Column(db.String(255), nullable=True)
|
||
|
||
incoming_folder = db.Column(db.String(255), nullable=True)
|
||
processed_folder = db.Column(db.String(255), nullable=True)
|
||
|
||
# Import configuration
|
||
auto_import_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
||
auto_import_interval_minutes = db.Column(db.Integer, nullable=False, default=15)
|
||
auto_import_max_items = db.Column(db.Integer, nullable=False, default=50)
|
||
manual_import_batch_size = db.Column(db.Integer, nullable=False, default=50)
|
||
auto_import_cutoff_date = db.Column(db.Date, nullable=True)
|
||
|
||
# Debug storage: store raw EML in database for a limited retention window.
|
||
# 0 = disabled, 7/14 = retention days.
|
||
ingest_eml_retention_days = db.Column(db.Integer, nullable=False, default=7)
|
||
|
||
# Daily Jobs: from which date 'Missed' status should start to be applied.
|
||
daily_jobs_start_date = db.Column(db.Date, nullable=True)
|
||
|
||
# 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)
|
||
|
||
# Development/Sandbox environment indicator.
|
||
# When enabled, a visual banner is displayed on all pages to indicate
|
||
# this is not a production environment.
|
||
is_sandbox_environment = db.Column(db.Boolean, nullable=False, default=False)
|
||
|
||
# Login page captcha (simple math question). Default True for new installs.
|
||
login_captcha_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||
|
||
# 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)
|
||
|
||
# Cove workstation offline detection (colorbar-based).
|
||
# When enabled, Cove workstation jobs receive a synthetic warning/error
|
||
# JobRun if their 28-day colorbar shows extended inactivity.
|
||
cove_offline_detection_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
||
cove_workstation_warning_days = db.Column(db.Integer, nullable=False, default=7)
|
||
cove_workstation_error_days = db.Column(db.Integer, nullable=False, default=14)
|
||
|
||
# Microsoft Entra SSO settings
|
||
entra_sso_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
||
entra_tenant_id = db.Column(db.String(128), nullable=True)
|
||
entra_client_id = db.Column(db.String(128), nullable=True)
|
||
entra_client_secret = db.Column(db.String(255), nullable=True)
|
||
entra_redirect_uri = db.Column(db.String(512), nullable=True)
|
||
entra_allowed_domain = db.Column(db.String(255), nullable=True)
|
||
entra_allowed_group_ids = db.Column(db.Text, nullable=True) # comma/newline separated Entra Group Object IDs
|
||
entra_auto_provision_users = 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
|
||
)
|
||
|
||
|
||
|
||
class AuditLog(db.Model):
|
||
__tablename__ = "audit_logs"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
user = db.Column(db.String(255), nullable=True)
|
||
event_type = db.Column(db.String(64), nullable=False)
|
||
message = db.Column(db.Text, nullable=False)
|
||
details = db.Column(db.Text, nullable=True)
|
||
|
||
|
||
# Legacy alias for backwards compatibility during migration
|
||
AdminLog = AuditLog
|
||
|
||
class Customer(db.Model):
|
||
__tablename__ = "customers"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
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
|
||
)
|
||
|
||
class Override(db.Model):
|
||
__tablename__ = "overrides"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
# Level of the override: global or object (job-level is no longer used)
|
||
level = db.Column(db.String(20), nullable=False)
|
||
|
||
# Scope for global overrides (optional wildcard fields)
|
||
backup_software = db.Column(db.String(255), nullable=True)
|
||
backup_type = db.Column(db.String(255), nullable=True)
|
||
|
||
# Scope for object overrides
|
||
job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=True)
|
||
object_name = db.Column(db.String(255), nullable=True)
|
||
|
||
# Matching criteria on object status / error message
|
||
match_status = db.Column(db.String(32), nullable=True)
|
||
match_error_contains = db.Column(db.String(255), nullable=True)
|
||
# Matching mode for error text: contains (default), exact, starts_with, ends_with
|
||
match_error_mode = db.Column(db.String(20), nullable=True)
|
||
|
||
# Behaviour flags
|
||
treat_as_success = db.Column(db.Boolean, nullable=False, default=True)
|
||
active = db.Column(db.Boolean, nullable=False, default=True)
|
||
|
||
# Validity window
|
||
start_at = db.Column(db.DateTime, nullable=False)
|
||
end_at = db.Column(db.DateTime, nullable=True)
|
||
|
||
# Management metadata
|
||
comment = db.Column(db.Text, nullable=True)
|
||
created_by = db.Column(db.String(255), nullable=True)
|
||
updated_by = db.Column(db.String(255), 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
|
||
)
|
||
|
||
class Job(db.Model):
|
||
__tablename__ = "jobs"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"), nullable=True)
|
||
|
||
backup_software = db.Column(db.String(128), nullable=True)
|
||
backup_type = db.Column(db.String(128), nullable=True)
|
||
job_name = db.Column(db.String(512), nullable=True)
|
||
|
||
from_address = db.Column(db.String(512), nullable=True)
|
||
|
||
schedule_type = db.Column(db.String(32), nullable=True) # daily, weekly, monthly, yearly
|
||
schedule_days_of_week = db.Column(db.String(64), nullable=True) # e.g. "Mon,Tue,Wed"
|
||
schedule_day_of_month = db.Column(db.Integer, nullable=True) # 1-31
|
||
schedule_times = db.Column(db.String(255), nullable=True) # e.g. "01:00,13:15"
|
||
|
||
auto_approve = db.Column(db.Boolean, nullable=False, default=True)
|
||
active = db.Column(db.Boolean, nullable=False, default=True)
|
||
|
||
# Cove Data Protection integration (legacy: account ID stored directly on job)
|
||
cove_account_id = db.Column(db.Integer, nullable=True) # kept for backwards compat
|
||
|
||
# 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)
|
||
archived_at = db.Column(db.DateTime, nullable=True)
|
||
archived_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
updated_at = db.Column(
|
||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||
)
|
||
|
||
customer = db.relationship(
|
||
"Customer",
|
||
backref=db.backref("jobs", lazy="dynamic"),
|
||
lazy="joined",
|
||
)
|
||
|
||
|
||
class JobRun(db.Model):
|
||
__tablename__ = "job_runs"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=False)
|
||
mail_message_id = db.Column(db.Integer, db.ForeignKey("mail_messages.id"), nullable=True)
|
||
|
||
run_at = db.Column(db.DateTime, nullable=True)
|
||
status = db.Column(db.String(64), nullable=True)
|
||
remark = db.Column(db.Text, nullable=True)
|
||
missed = db.Column(db.Boolean, nullable=False, default=False)
|
||
override_applied = db.Column(db.Boolean, nullable=False, default=False)
|
||
|
||
# Override metadata for reporting/auditing.
|
||
# These are populated when override flags are recomputed.
|
||
override_applied_override_id = db.Column(db.Integer, nullable=True)
|
||
override_applied_level = db.Column(db.String(16), nullable=True)
|
||
override_applied_reason = db.Column(db.Text, nullable=True)
|
||
|
||
# Optional storage metrics (e.g. for repository capacity monitoring)
|
||
storage_used_bytes = db.Column(db.BigInteger, nullable=True)
|
||
storage_capacity_bytes = db.Column(db.BigInteger, nullable=True)
|
||
storage_free_bytes = db.Column(db.BigInteger, nullable=True)
|
||
storage_free_percent = db.Column(db.Float, nullable=True)
|
||
|
||
# Run review (Run Checks)
|
||
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)
|
||
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(
|
||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||
)
|
||
|
||
job = db.relationship(
|
||
"Job",
|
||
backref=db.backref("runs", lazy="dynamic", cascade="all, delete-orphan"),
|
||
)
|
||
|
||
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 CoveAccount(db.Model):
|
||
"""Staging table for Cove Data Protection accounts.
|
||
|
||
All accounts returned by EnumerateAccountStatistics are upserted here.
|
||
Unlinked accounts (job_id IS NULL) appear in the Cove Accounts page
|
||
where an admin can create or link a job – the same flow as the mail Inbox.
|
||
Once linked, the importer creates JobRuns for each new session.
|
||
"""
|
||
__tablename__ = "cove_accounts"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
# Cove account identifier (unique, from AccountId field)
|
||
account_id = db.Column(db.Integer, nullable=False, unique=True)
|
||
|
||
# Account/device info from Cove columns
|
||
account_name = db.Column(db.String(512), nullable=True) # I1 – device/backup name
|
||
computer_name = db.Column(db.String(512), nullable=True) # I18 – computer name
|
||
customer_name = db.Column(db.String(255), nullable=True) # I8 – Cove customer/partner name
|
||
datasource_types = db.Column(db.String(255), nullable=True) # I78 – active datasource label
|
||
|
||
# Last known status
|
||
last_status_code = db.Column(db.Integer, nullable=True) # D09F00
|
||
last_run_at = db.Column(db.DateTime, nullable=True) # D09F15 (converted from Unix ts)
|
||
colorbar_28d = db.Column(db.String(64), nullable=True) # D09F08
|
||
|
||
# Link to a Backupchecks job (NULL = unmatched, needs review)
|
||
job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=True)
|
||
|
||
first_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
last_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
job = db.relationship("Job", backref=db.backref("cove_account", uselist=False))
|
||
|
||
|
||
class CloudConnectAccount(db.Model):
|
||
"""Staging table for Veeam Cloud Connect tenant accounts.
|
||
|
||
Each row represents one User × section (Backup / Agent) combination
|
||
as found in the Veeam Cloud Connect daily report email.
|
||
|
||
Unlinked accounts (job_id IS NULL) appear on the Cloud Connect Accounts
|
||
review page where an admin can create or link a Backupchecks job —
|
||
identical to the Cove Accounts flow.
|
||
"""
|
||
__tablename__ = "cloud_connect_accounts"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
user = db.Column(db.String(255), nullable=False)
|
||
section = db.Column(db.String(32), nullable=False)
|
||
|
||
repo_name = db.Column(db.String(512), nullable=False, default="")
|
||
repo_type = db.Column(db.String(255), nullable=True)
|
||
num_items = db.Column(db.String(64), nullable=True)
|
||
total_quota = db.Column(db.String(32), nullable=True)
|
||
used_space = db.Column(db.String(32), nullable=True)
|
||
free_space = db.Column(db.String(32), nullable=True)
|
||
last_active_raw = db.Column(db.String(64), nullable=True)
|
||
last_active_dt = db.Column(db.DateTime, nullable=True)
|
||
last_status = db.Column(db.String(32), nullable=True)
|
||
|
||
last_mail_message_id = db.Column(db.Integer, db.ForeignKey("mail_messages.id"), nullable=True)
|
||
job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=True)
|
||
|
||
first_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
last_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
job = db.relationship("Job", backref=db.backref("cloud_connect_account", uselist=False))
|
||
|
||
__table_args__ = (
|
||
db.UniqueConstraint("user", "section", "repo_name", name="uq_cloud_connect_accounts_user_section_repo"),
|
||
)
|
||
|
||
|
||
class JobRunReviewEvent(db.Model):
|
||
__tablename__ = "job_run_review_events"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
run_id = db.Column(db.Integer, db.ForeignKey("job_runs.id"), nullable=False)
|
||
action = db.Column(db.String(32), nullable=False) # REVIEWED | UNREVIEWED
|
||
actor_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||
note = db.Column(db.Text, nullable=True)
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
run = db.relationship(
|
||
"JobRun",
|
||
backref=db.backref("review_events", lazy="dynamic", cascade="all, delete-orphan"),
|
||
)
|
||
actor = db.relationship("User", foreign_keys=[actor_user_id])
|
||
|
||
|
||
class JobObject(db.Model):
|
||
__tablename__ = "job_objects"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
job_run_id = db.Column(db.Integer, db.ForeignKey("job_runs.id"), nullable=False)
|
||
object_name = db.Column(db.String(512), nullable=False)
|
||
object_type = db.Column(db.String(128), nullable=True)
|
||
status = db.Column(db.String(64), nullable=True)
|
||
error_message = db.Column(db.Text, nullable=True)
|
||
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
job_run = db.relationship(
|
||
"JobRun",
|
||
backref=db.backref("objects", lazy="dynamic", cascade="all, delete-orphan"),
|
||
)
|
||
|
||
|
||
class MailMessage(db.Model):
|
||
__tablename__ = "mail_messages"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
# Basic mail metadata
|
||
message_id = db.Column(db.String(512), unique=True, nullable=True)
|
||
from_address = db.Column(db.String(512), nullable=True)
|
||
subject = db.Column(db.String(1024), nullable=True)
|
||
received_at = db.Column(db.DateTime, nullable=True)
|
||
|
||
# Parsed backup metadata
|
||
backup_software = db.Column(db.String(128), nullable=True)
|
||
backup_type = db.Column(db.String(128), nullable=True)
|
||
job_name = db.Column(db.String(512), nullable=True)
|
||
|
||
from_address = db.Column(db.String(512), nullable=True)
|
||
overall_status = db.Column(db.String(32), nullable=True)
|
||
overall_message = db.Column(db.Text, nullable=True)
|
||
parse_result = db.Column(db.String(32), nullable=True)
|
||
parse_error = db.Column(db.String(512), nullable=True)
|
||
|
||
parsed_at = db.Column(db.DateTime, nullable=True)
|
||
|
||
# Optional storage metrics (e.g. repository capacity monitoring)
|
||
storage_used_bytes = db.Column(db.BigInteger, nullable=True)
|
||
storage_capacity_bytes = db.Column(db.BigInteger, nullable=True)
|
||
storage_free_bytes = db.Column(db.BigInteger, nullable=True)
|
||
storage_free_percent = db.Column(db.Float, nullable=True)
|
||
|
||
|
||
# Link back to Job and location (inbox/history)
|
||
job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=True)
|
||
location = db.Column(db.String(32), nullable=False, default="inbox")
|
||
|
||
# Raw / rendered content storage (for inline popup)
|
||
html_body = db.Column(db.Text, nullable=True)
|
||
text_body = db.Column(db.Text, nullable=True)
|
||
|
||
# Optional raw RFC822 message storage (debug) - controlled by SystemSettings.ingest_eml_retention_days
|
||
eml_blob = db.Column(db.LargeBinary, nullable=True)
|
||
eml_stored_at = db.Column(db.DateTime, nullable=True)
|
||
|
||
# Approval metadata
|
||
approved = db.Column(db.Boolean, nullable=False, default=False)
|
||
approved_at = db.Column(db.DateTime, nullable=True)
|
||
approved_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||
|
||
# Soft-delete metadata (Inbox delete -> Admin restore)
|
||
deleted_at = db.Column(db.DateTime, nullable=True)
|
||
deleted_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
updated_at = db.Column(
|
||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||
)
|
||
|
||
deleted_by_user = db.relationship("User", foreign_keys=[deleted_by_user_id])
|
||
|
||
class MailObject(db.Model):
|
||
__tablename__ = "mail_objects"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
mail_message_id = db.Column(db.Integer, db.ForeignKey("mail_messages.id"), nullable=False)
|
||
object_name = db.Column(db.String(512), nullable=False)
|
||
object_type = db.Column(db.String(128), nullable=True)
|
||
status = db.Column(db.String(64), nullable=True)
|
||
error_message = db.Column(db.Text, nullable=True)
|
||
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
|
||
|
||
class Ticket(db.Model):
|
||
__tablename__ = "tickets"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
ticket_code = db.Column(db.String(32), unique=True, nullable=False)
|
||
title = db.Column(db.String(255))
|
||
description = db.Column(db.Text)
|
||
|
||
# Date (Europe/Amsterdam) from which this ticket should be considered active
|
||
# for the scoped job(s) in Daily Jobs / Job Details views.
|
||
active_from_date = db.Column(db.Date, nullable=False)
|
||
|
||
# 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)
|
||
|
||
|
||
class TicketScope(db.Model):
|
||
__tablename__ = "ticket_scopes"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False)
|
||
scope_type = db.Column(db.String(32), nullable=False)
|
||
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"))
|
||
backup_software = db.Column(db.String(128))
|
||
backup_type = db.Column(db.String(128))
|
||
job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"))
|
||
job_name_match = db.Column(db.String(255))
|
||
job_name_match_mode = db.Column(db.String(32))
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
resolved_at = db.Column(db.DateTime)
|
||
|
||
|
||
class TicketJobRun(db.Model):
|
||
__tablename__ = "ticket_job_runs"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False)
|
||
job_run_id = db.Column(db.Integer, db.ForeignKey("job_runs.id"), nullable=False)
|
||
linked_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
link_source = db.Column(db.String(64), nullable=False)
|
||
__table_args__ = (db.UniqueConstraint("ticket_id", "job_run_id", name="uq_ticket_job_run"),)
|
||
|
||
|
||
class Remark(db.Model):
|
||
__tablename__ = "remarks"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
title = db.Column(db.String(255))
|
||
body = db.Column(db.Text, nullable=False)
|
||
source = db.Column(db.String(64), nullable=False, default="manual")
|
||
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=True)
|
||
|
||
# Date (Europe/Amsterdam) from which this remark should be considered active
|
||
# for the scoped job(s) in Daily Jobs / Job Details views.
|
||
active_from_date = db.Column(db.Date)
|
||
|
||
start_date = db.Column(db.DateTime)
|
||
resolved_at = db.Column(db.DateTime)
|
||
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)
|
||
|
||
|
||
class RemarkScope(db.Model):
|
||
__tablename__ = "remark_scopes"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
remark_id = db.Column(db.Integer, db.ForeignKey("remarks.id"), nullable=False)
|
||
scope_type = db.Column(db.String(32), nullable=False)
|
||
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"))
|
||
backup_software = db.Column(db.String(128))
|
||
backup_type = db.Column(db.String(128))
|
||
job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"))
|
||
job_name_match = db.Column(db.String(255))
|
||
job_name_match_mode = db.Column(db.String(32))
|
||
job_run_id = db.Column(db.Integer, db.ForeignKey("job_runs.id"))
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
|
||
class RemarkJobRun(db.Model):
|
||
__tablename__ = "remark_job_runs"
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
remark_id = db.Column(db.Integer, db.ForeignKey("remarks.id"), nullable=False)
|
||
job_run_id = db.Column(db.Integer, db.ForeignKey("job_runs.id"), nullable=False)
|
||
linked_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
link_source = db.Column(db.String(64), nullable=False)
|
||
__table_args__ = (db.UniqueConstraint("remark_id", "job_run_id", name="uq_remark_job_run"),)
|
||
|
||
|
||
class FeedbackItem(db.Model):
|
||
__tablename__ = "feedback_items"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
# bug | feature
|
||
item_type = db.Column(db.String(16), nullable=False)
|
||
title = db.Column(db.String(255), nullable=False)
|
||
description = db.Column(db.Text, nullable=False)
|
||
component = db.Column(db.String(255), nullable=True)
|
||
|
||
# open | resolved
|
||
status = db.Column(db.String(16), nullable=False, default="open")
|
||
|
||
created_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||
resolved_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||
resolved_at = db.Column(db.DateTime, nullable=True)
|
||
|
||
deleted_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||
deleted_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
|
||
)
|
||
|
||
|
||
class FeedbackVote(db.Model):
|
||
__tablename__ = "feedback_votes"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
feedback_item_id = db.Column(
|
||
db.Integer, db.ForeignKey("feedback_items.id"), nullable=False
|
||
)
|
||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
__table_args__ = (
|
||
db.UniqueConstraint(
|
||
"feedback_item_id", "user_id", name="uq_feedback_vote_item_user"
|
||
),
|
||
)
|
||
|
||
|
||
|
||
|
||
class FeedbackReply(db.Model):
|
||
__tablename__ = "feedback_replies"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
feedback_item_id = db.Column(
|
||
db.Integer, db.ForeignKey("feedback_items.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||
message = db.Column(db.Text, nullable=False)
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
|
||
class FeedbackAttachment(db.Model):
|
||
__tablename__ = "feedback_attachments"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
feedback_item_id = db.Column(
|
||
db.Integer, db.ForeignKey("feedback_items.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
feedback_reply_id = db.Column(
|
||
db.Integer, db.ForeignKey("feedback_replies.id", ondelete="CASCADE"), nullable=True
|
||
)
|
||
filename = db.Column(db.String(255), nullable=False)
|
||
file_data = db.Column(db.LargeBinary, nullable=False)
|
||
mime_type = db.Column(db.String(64), nullable=False)
|
||
file_size = db.Column(db.Integer, nullable=False)
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
|
||
class NewsItem(db.Model):
|
||
__tablename__ = "news_items"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
title = db.Column(db.String(255), nullable=False)
|
||
body = db.Column(db.Text, nullable=False)
|
||
|
||
link_url = db.Column(db.String(2048), nullable=True)
|
||
|
||
severity = db.Column(db.String(32), nullable=False, default="info") # info, warning
|
||
pinned = db.Column(db.Boolean, nullable=False, default=False)
|
||
active = db.Column(db.Boolean, nullable=False, default=True)
|
||
|
||
publish_from = db.Column(db.DateTime, nullable=True)
|
||
publish_until = db.Column(db.DateTime, nullable=True)
|
||
|
||
created_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
updated_at = db.Column(
|
||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||
)
|
||
|
||
|
||
class NewsRead(db.Model):
|
||
__tablename__ = "news_reads"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
news_item_id = db.Column(db.Integer, db.ForeignKey("news_items.id"), nullable=False)
|
||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||
|
||
read_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
# --- Reporting (phase 1: raw data foundation) ---------------------------------
|
||
|
||
class ReportDefinition(db.Model):
|
||
__tablename__ = "report_definitions"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
name = db.Column(db.String(255), nullable=False)
|
||
description = db.Column(db.Text, nullable=True)
|
||
|
||
# one-time | scheduled
|
||
report_type = db.Column(db.String(32), nullable=False, default="one-time")
|
||
|
||
# csv | html | pdf
|
||
output_format = db.Column(db.String(16), nullable=False, default="csv")
|
||
|
||
# customer scope for report generation
|
||
# all | single | multiple
|
||
customer_scope = db.Column(db.String(16), nullable=False, default="all")
|
||
# JSON encoded list of customer ids. NULL/empty when scope=all.
|
||
customer_ids = db.Column(db.Text, nullable=True)
|
||
|
||
period_start = db.Column(db.DateTime, nullable=False)
|
||
period_end = db.Column(db.DateTime, nullable=False)
|
||
|
||
# For scheduled reports in later phases (cron / RRULE style string)
|
||
schedule = db.Column(db.String(255), nullable=True)
|
||
|
||
# JSON report definition for UI (columns, charts, filters, templates)
|
||
# Stored as TEXT to remain flexible and allow future PDF rendering.
|
||
report_config = db.Column(db.Text, nullable=True)
|
||
|
||
created_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
updated_at = db.Column(
|
||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||
)
|
||
|
||
created_by = db.relationship("User", foreign_keys=[created_by_user_id])
|
||
|
||
|
||
class ReportObjectSnapshot(db.Model):
|
||
__tablename__ = "report_object_snapshots"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
report_id = db.Column(db.Integer, db.ForeignKey("report_definitions.id"), nullable=False)
|
||
|
||
# Object identity (from customer_objects.object_name)
|
||
object_name = db.Column(db.Text, nullable=False)
|
||
|
||
# Job identity
|
||
job_id = db.Column(db.Integer, nullable=True)
|
||
job_name = db.Column(db.Text, nullable=True)
|
||
customer_id = db.Column(db.Integer, nullable=True)
|
||
customer_name = db.Column(db.Text, nullable=True)
|
||
|
||
backup_software = db.Column(db.Text, nullable=True)
|
||
backup_type = db.Column(db.Text, nullable=True)
|
||
|
||
# Run identity
|
||
run_id = db.Column(db.Integer, nullable=True)
|
||
run_at = db.Column(db.DateTime, nullable=True)
|
||
|
||
status = db.Column(db.Text, nullable=True)
|
||
missed = db.Column(db.Boolean, nullable=False, default=False)
|
||
override_applied = db.Column(db.Boolean, nullable=False, default=False)
|
||
|
||
reviewed_at = db.Column(db.DateTime, nullable=True)
|
||
ticket_number = db.Column(db.Text, nullable=True)
|
||
remark = db.Column(db.Text, nullable=True)
|
||
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
report = db.relationship(
|
||
"ReportDefinition",
|
||
backref=db.backref("object_snapshots", lazy="dynamic", cascade="all, delete-orphan"),
|
||
)
|
||
|
||
|
||
class ReportObjectSummary(db.Model):
|
||
__tablename__ = "report_object_summaries"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
|
||
report_id = db.Column(db.Integer, db.ForeignKey("report_definitions.id"), nullable=False)
|
||
object_name = db.Column(db.Text, nullable=False)
|
||
|
||
customer_id = db.Column(db.Integer, nullable=True)
|
||
customer_name = db.Column(db.Text, nullable=True)
|
||
|
||
total_runs = db.Column(db.Integer, nullable=False, default=0)
|
||
success_count = db.Column(db.Integer, nullable=False, default=0)
|
||
success_override_count = db.Column(db.Integer, nullable=False, default=0)
|
||
warning_count = db.Column(db.Integer, nullable=False, default=0)
|
||
failed_count = db.Column(db.Integer, nullable=False, default=0)
|
||
missed_count = db.Column(db.Integer, nullable=False, default=0)
|
||
|
||
success_rate = db.Column(db.Float, nullable=False, default=0.0)
|
||
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||
|
||
report = db.relationship(
|
||
"ReportDefinition",
|
||
backref=db.backref("object_summaries", lazy="dynamic", cascade="all, delete-orphan"),
|
||
)
|