backupchecks/containers/backupchecks/src/backend/app/models.py
Ivo Oskamp f21d6f4fca Release v0.3.0
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).
2026-05-01 11:04:35 +02:00

849 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"),
)