Files
TimeTracker/app/models/settings.py
T
Dries Peeters ea913c6c4b feat(ai,security): add web AI helper, secret encryption, and 2FA
Introduce a web-first AI helper with admin-configurable providers (Ollama or hosted OpenAI-compatible), server-side context building, and confirmed write actions. Expose the feature via session /api/ai/* endpoints and scoped /api/v1/ai/* endpoints.

Harden security by requiring a strong SECRET_KEY for Docker Compose, adding optional settings encryption-at-rest (Fernet), and introducing TOTP-based 2FA plus password reset flows. Update admin UI, API docs, and install documentation.
2026-04-26 07:55:47 +02:00

913 lines
49 KiB
Python

import os
import threading
from datetime import datetime
from app import db
from app.config import Config
from app.utils.invoice_numbering import DEFAULT_INVOICE_PATTERN
from app.utils.secret_crypto import decrypt_if_needed, encrypt_if_possible, is_configured as secrets_encryption_configured
# Re-entrancy guard: avoid add+commit when get_settings is called from inside a flush/commit
_creating_settings = threading.local()
def _session_in_flush(session):
"""Return True if the session is currently in a flush (to avoid nested add+commit)."""
try:
# SQLAlchemy sets _flushing for the outer flush() call.
if getattr(session, "_flushing", False):
return True
# During flush_context.execute(), SA 2.0 sets _warn_on_events while disallowing
# Session.add() etc. ScopedSession/proxy edge cases can leave _flushing unreadable;
# _warn_on_events reliably marks the "execution stage" of a flush.
if getattr(session, "_warn_on_events", False):
return True
# Fallback: in a transaction and inside a flush context (if exposed)
if (
getattr(session, "in_transaction", lambda: False)()
and getattr(session, "_current_flush_context", None) is not None
):
return True
return False
except Exception:
return False
class Settings(db.Model):
"""Settings model for system configuration"""
__tablename__ = "settings"
id = db.Column(db.Integer, primary_key=True)
timezone = db.Column(db.String(50), default="Europe/Rome", nullable=False)
date_format = db.Column(
db.String(20), default="YYYY-MM-DD", nullable=False
) # YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY, DD.MM.YYYY
time_format = db.Column(db.String(10), default="24h", nullable=False) # 24h or 12h
currency = db.Column(db.String(3), default="EUR", nullable=False)
rounding_minutes = db.Column(db.Integer, default=1, nullable=False)
single_active_timer = db.Column(db.Boolean, default=True, nullable=False)
allow_self_register = db.Column(db.Boolean, default=True, nullable=False)
idle_timeout_minutes = db.Column(db.Integer, default=30, nullable=False)
backup_retention_days = db.Column(db.Integer, default=30, nullable=False)
backup_time = db.Column(db.String(5), default="02:00", nullable=False) # HH:MM format
export_delimiter = db.Column(db.String(1), default=",", nullable=False)
# Company branding for invoices
company_name = db.Column(db.String(200), default="Your Company Name", nullable=False)
company_address = db.Column(db.Text, default="Your Company Address", nullable=False)
company_email = db.Column(db.String(200), default="info@yourcompany.com", nullable=False)
company_phone = db.Column(db.String(50), default="+1 (555) 123-4567", nullable=False)
company_website = db.Column(db.String(200), default="www.yourcompany.com", nullable=False)
company_logo_filename = db.Column(db.String(255), default="", nullable=True) # Changed from company_logo_path
company_tax_id = db.Column(db.String(100), default="", nullable=True)
company_bank_info = db.Column(db.Text, default="", nullable=True)
# PDF template customization
invoice_pdf_template_html = db.Column(db.Text, default="", nullable=True)
invoice_pdf_template_css = db.Column(db.Text, default="", nullable=True)
invoice_pdf_design_json = db.Column(db.Text, default="", nullable=True) # Konva.js design state
# Invoice defaults
invoice_prefix = db.Column(db.String(50), default="INV", nullable=False)
invoice_number_pattern = db.Column(db.String(120), default=DEFAULT_INVOICE_PATTERN, nullable=False)
invoice_start_number = db.Column(db.Integer, default=1000, nullable=False)
invoice_terms = db.Column(db.Text, default="Payment is due within 30 days of invoice date.", nullable=False)
invoice_notes = db.Column(db.Text, default="Thank you for your business!", nullable=False)
# Peppol e-invoicing (optional; can be configured via WebUI or env)
# peppol_enabled: None => use env var PEPPOL_ENABLED; True/False overrides env.
peppol_enabled = db.Column(db.Boolean, default=None, nullable=True)
peppol_sender_endpoint_id = db.Column(db.String(100), default="", nullable=True)
peppol_sender_scheme_id = db.Column(db.String(20), default="", nullable=True)
peppol_sender_country = db.Column(db.String(2), default="", nullable=True)
peppol_access_point_url = db.Column(db.String(500), default="", nullable=True)
peppol_access_point_token = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production
peppol_access_point_timeout = db.Column(db.Integer, default=30, nullable=True)
peppol_provider = db.Column(db.String(50), default="generic", nullable=True)
# Transport: generic (HTTP JSON AP) or native (SML/SMP + AS4)
peppol_transport_mode = db.Column(db.String(20), default="generic", nullable=True)
peppol_sml_url = db.Column(db.String(500), default="", nullable=True)
peppol_native_cert_path = db.Column(db.String(500), default="", nullable=True)
peppol_native_key_path = db.Column(db.String(500), default="", nullable=True)
invoices_peppol_compliant = db.Column(db.Boolean, default=False, nullable=False)
# When True, exported invoice PDFs embed EN 16931 UBL XML (ZugFerd/Factur-X)
invoices_zugferd_pdf = db.Column(db.Boolean, default=False, nullable=False)
# When True and ZUGFeRD is on, export is normalized to PDF/A-3 for validators
invoices_pdfa3_compliant = db.Column(db.Boolean, default=False, nullable=False)
# Optional: run veraPDF after export and show summary (does not block export)
invoices_validate_export = db.Column(db.Boolean, default=False, nullable=False)
invoices_verapdf_path = db.Column(db.String(500), default="", nullable=True)
# Privacy and analytics settings
allow_analytics = db.Column(db.Boolean, default=True, nullable=False) # Controls system info sharing for analytics
# Module visibility: admin-disabled module IDs (e.g. ["gantt", "leads"]). Empty/None = all enabled.
disabled_module_ids = db.Column(db.JSON, default=list, nullable=True)
# Optional: lock the app to a single client (company-only usage).
# When set, the UI should auto-select this client and prevent changes.
locked_client_id = db.Column(db.Integer, nullable=True)
# Stable per-installation ID (UUID); used for donate-hide code requests.
system_instance_id = db.Column(db.String(36), nullable=True)
# When True, donate/support UI is hidden for all users (set after code verification in Admin).
donate_ui_hidden = db.Column(db.Boolean, default=False, nullable=False)
# Kiosk mode settings
kiosk_mode_enabled = db.Column(db.Boolean, default=False, nullable=False)
kiosk_auto_logout_minutes = db.Column(db.Integer, default=15, nullable=False)
kiosk_allow_camera_scanning = db.Column(db.Boolean, default=True, nullable=False)
kiosk_require_reason_for_adjustments = db.Column(db.Boolean, default=False, nullable=False)
kiosk_default_movement_type = db.Column(db.String(20), default="adjustment", nullable=False)
# Time entry requirements (admin-enforced when logging time)
time_entry_require_task = db.Column(db.Boolean, default=False, nullable=False)
time_entry_require_description = db.Column(db.Boolean, default=False, nullable=False)
time_entry_description_min_length = db.Column(db.Integer, default=20, nullable=False)
# AI helper provider configuration. API keys stay server-side and are never serialized.
ai_enabled = db.Column(db.Boolean, default=None, nullable=True)
ai_provider = db.Column(db.String(50), default="", nullable=True)
ai_base_url = db.Column(db.String(500), default="", nullable=True)
ai_model = db.Column(db.String(120), default="", nullable=True)
ai_api_key = db.Column(db.String(500), default="", nullable=True)
ai_timeout_seconds = db.Column(db.Integer, default=None, nullable=True)
ai_context_limit = db.Column(db.Integer, default=None, nullable=True)
ai_system_prompt = db.Column(db.Text, default="", nullable=True)
# Overtime / time tracking: default daily working hours for new users (e.g. 8.0)
default_daily_working_hours = db.Column(db.Float, default=8.0, nullable=False)
# Default break rules for time entries (e.g. Germany: >6h = 30 min, >9h = 45 min). User can override per entry.
break_after_hours_1 = db.Column(db.Float, nullable=True) # e.g. 6
break_minutes_1 = db.Column(db.Integer, nullable=True) # e.g. 30
break_after_hours_2 = db.Column(db.Float, nullable=True) # e.g. 9
break_minutes_2 = db.Column(db.Integer, nullable=True) # e.g. 45
# Email configuration settings (stored in database, takes precedence over environment variables)
mail_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable database-backed email config
mail_server = db.Column(db.String(255), default="", nullable=True)
mail_port = db.Column(db.Integer, default=587, nullable=True)
mail_use_tls = db.Column(db.Boolean, default=True, nullable=True)
mail_use_ssl = db.Column(db.Boolean, default=False, nullable=True)
mail_username = db.Column(db.String(255), default="", nullable=True)
mail_password = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production
mail_default_sender = db.Column(db.String(255), default="", nullable=True)
mail_test_recipient = db.Column(db.String(255), default="", nullable=True)
# Integration OAuth credentials (stored in database, takes precedence over environment variables)
# Jira
jira_client_id = db.Column(db.String(255), default="", nullable=True)
jira_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production
# Slack
slack_client_id = db.Column(db.String(255), default="", nullable=True)
slack_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production
# GitHub
github_client_id = db.Column(db.String(255), default="", nullable=True)
github_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production
# Google Calendar
google_calendar_client_id = db.Column(db.String(255), default="", nullable=True)
google_calendar_client_secret = db.Column(
db.String(255), default="", nullable=True
) # Store encrypted in production
# Outlook Calendar
outlook_calendar_client_id = db.Column(db.String(255), default="", nullable=True)
outlook_calendar_client_secret = db.Column(
db.String(255), default="", nullable=True
) # Store encrypted in production
outlook_calendar_tenant_id = db.Column(db.String(255), default="", nullable=True)
# Microsoft Teams
microsoft_teams_client_id = db.Column(db.String(255), default="", nullable=True)
microsoft_teams_client_secret = db.Column(
db.String(255), default="", nullable=True
) # Store encrypted in production
microsoft_teams_tenant_id = db.Column(db.String(255), default="", nullable=True)
# Asana
asana_client_id = db.Column(db.String(255), default="", nullable=True)
asana_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production
# Trello
trello_api_key = db.Column(db.String(255), default="", nullable=True)
trello_api_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production
# GitLab
gitlab_client_id = db.Column(db.String(255), default="", nullable=True)
gitlab_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production
gitlab_instance_url = db.Column(db.String(500), default="", nullable=True)
# QuickBooks
quickbooks_client_id = db.Column(db.String(255), default="", nullable=True)
quickbooks_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production
# Xero
xero_client_id = db.Column(db.String(255), default="", nullable=True)
xero_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production
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)
def __init__(self, **kwargs):
# Set defaults from config
self.timezone = kwargs.get("timezone", Config.TZ)
self.date_format = kwargs.get("date_format", "YYYY-MM-DD")
self.time_format = kwargs.get("time_format", "24h")
self.currency = kwargs.get("currency", Config.CURRENCY)
self.rounding_minutes = kwargs.get("rounding_minutes", Config.ROUNDING_MINUTES)
self.single_active_timer = kwargs.get("single_active_timer", Config.SINGLE_ACTIVE_TIMER)
self.allow_self_register = kwargs.get("allow_self_register", Config.ALLOW_SELF_REGISTER)
self.idle_timeout_minutes = kwargs.get("idle_timeout_minutes", Config.IDLE_TIMEOUT_MINUTES)
self.backup_retention_days = kwargs.get("backup_retention_days", Config.BACKUP_RETENTION_DAYS)
self.backup_time = kwargs.get("backup_time", Config.BACKUP_TIME)
self.export_delimiter = kwargs.get("export_delimiter", ",")
# Set company branding defaults
self.company_name = kwargs.get("company_name", "Your Company Name")
self.company_address = kwargs.get("company_address", "Your Company Address")
self.company_email = kwargs.get("company_email", "info@yourcompany.com")
self.company_phone = kwargs.get("company_phone", "+1 (555) 123-4567")
self.company_website = kwargs.get("company_website", "www.yourcompany.com")
self.company_logo_filename = kwargs.get("company_logo_filename", "")
self.company_tax_id = kwargs.get("company_tax_id", "")
self.company_bank_info = kwargs.get("company_bank_info", "")
# PDF template customization
self.invoice_pdf_template_html = kwargs.get("invoice_pdf_template_html", "")
self.invoice_pdf_template_css = kwargs.get("invoice_pdf_template_css", "")
self.invoice_pdf_design_json = kwargs.get("invoice_pdf_design_json", "")
# Set invoice defaults
self.invoice_prefix = kwargs.get("invoice_prefix", "INV")
self.invoice_number_pattern = kwargs.get("invoice_number_pattern", DEFAULT_INVOICE_PATTERN)
self.invoice_start_number = kwargs.get("invoice_start_number", 1000)
self.invoice_terms = kwargs.get("invoice_terms", "Payment is due within 30 days of invoice date.")
self.invoice_notes = kwargs.get("invoice_notes", "Thank you for your business!")
# Peppol defaults (None means "use env var")
self.peppol_enabled = kwargs.get("peppol_enabled", None)
self.peppol_sender_endpoint_id = kwargs.get("peppol_sender_endpoint_id", "")
self.peppol_sender_scheme_id = kwargs.get("peppol_sender_scheme_id", "")
self.peppol_sender_country = kwargs.get("peppol_sender_country", "")
self.peppol_access_point_url = kwargs.get("peppol_access_point_url", "")
self.peppol_access_point_token = kwargs.get("peppol_access_point_token", "")
self.peppol_access_point_timeout = kwargs.get("peppol_access_point_timeout", 30)
self.peppol_provider = kwargs.get("peppol_provider", "generic")
self.peppol_transport_mode = kwargs.get("peppol_transport_mode", "generic")
self.peppol_sml_url = kwargs.get("peppol_sml_url", "")
self.peppol_native_cert_path = kwargs.get("peppol_native_cert_path", "")
self.peppol_native_key_path = kwargs.get("peppol_native_key_path", "")
self.invoices_peppol_compliant = kwargs.get("invoices_peppol_compliant", False)
self.invoices_zugferd_pdf = kwargs.get("invoices_zugferd_pdf", False)
self.invoices_pdfa3_compliant = kwargs.get("invoices_pdfa3_compliant", False)
self.invoices_validate_export = kwargs.get("invoices_validate_export", False)
self.invoices_verapdf_path = kwargs.get("invoices_verapdf_path", "")
# Kiosk mode defaults
self.kiosk_mode_enabled = kwargs.get("kiosk_mode_enabled", False)
self.kiosk_auto_logout_minutes = kwargs.get("kiosk_auto_logout_minutes", 15)
self.kiosk_allow_camera_scanning = kwargs.get("kiosk_allow_camera_scanning", True)
self.kiosk_require_reason_for_adjustments = kwargs.get("kiosk_require_reason_for_adjustments", False)
self.kiosk_default_movement_type = kwargs.get("kiosk_default_movement_type", "adjustment")
# Email configuration defaults
self.mail_enabled = kwargs.get("mail_enabled", False)
self.mail_server = kwargs.get("mail_server", "")
self.mail_port = kwargs.get("mail_port", 587)
self.mail_use_tls = kwargs.get("mail_use_tls", True)
self.mail_use_ssl = kwargs.get("mail_use_ssl", False)
self.mail_username = kwargs.get("mail_username", "")
self.mail_password = kwargs.get("mail_password", "")
self.mail_default_sender = kwargs.get("mail_default_sender", "")
self.mail_test_recipient = kwargs.get("mail_test_recipient", "")
# AI helper defaults. None/empty values fall back to environment/app config.
self.ai_enabled = kwargs.get("ai_enabled", None)
self.ai_provider = kwargs.get("ai_provider", "")
self.ai_base_url = kwargs.get("ai_base_url", "")
self.ai_model = kwargs.get("ai_model", "")
self.ai_api_key = kwargs.get("ai_api_key", "")
self.ai_timeout_seconds = kwargs.get("ai_timeout_seconds", None)
self.ai_context_limit = kwargs.get("ai_context_limit", None)
self.ai_system_prompt = kwargs.get("ai_system_prompt", "")
# Integration OAuth credentials defaults
self.jira_client_id = kwargs.get("jira_client_id", "")
self.jira_client_secret = kwargs.get("jira_client_secret", "")
self.slack_client_id = kwargs.get("slack_client_id", "")
self.slack_client_secret = kwargs.get("slack_client_secret", "")
self.github_client_id = kwargs.get("github_client_id", "")
self.github_client_secret = kwargs.get("github_client_secret", "")
self.google_calendar_client_id = kwargs.get("google_calendar_client_id", "")
self.google_calendar_client_secret = kwargs.get("google_calendar_client_secret", "")
self.outlook_calendar_client_id = kwargs.get("outlook_calendar_client_id", "")
self.outlook_calendar_client_secret = kwargs.get("outlook_calendar_client_secret", "")
self.outlook_calendar_tenant_id = kwargs.get("outlook_calendar_tenant_id", "")
self.microsoft_teams_client_id = kwargs.get("microsoft_teams_client_id", "")
self.microsoft_teams_client_secret = kwargs.get("microsoft_teams_client_secret", "")
self.microsoft_teams_tenant_id = kwargs.get("microsoft_teams_tenant_id", "")
self.asana_client_id = kwargs.get("asana_client_id", "")
self.asana_client_secret = kwargs.get("asana_client_secret", "")
self.trello_api_key = kwargs.get("trello_api_key", "")
self.trello_api_secret = kwargs.get("trello_api_secret", "")
self.gitlab_client_id = kwargs.get("gitlab_client_id", "")
self.gitlab_client_secret = kwargs.get("gitlab_client_secret", "")
self.gitlab_instance_url = kwargs.get("gitlab_instance_url", "")
self.quickbooks_client_id = kwargs.get("quickbooks_client_id", "")
self.quickbooks_client_secret = kwargs.get("quickbooks_client_secret", "")
self.xero_client_id = kwargs.get("xero_client_id", "")
self.xero_client_secret = kwargs.get("xero_client_secret", "")
def __repr__(self):
return f"<Settings {self.id}>"
def get_logo_url(self):
"""Get the full URL for the company logo"""
if self.company_logo_filename:
return f"/uploads/logos/{self.company_logo_filename}"
return None
def get_logo_path(self):
"""Get the full file system path for the company logo"""
if not self.company_logo_filename:
return None
try:
from flask import current_app
upload_folder = os.path.join(current_app.root_path, "static", "uploads", "logos")
return os.path.join(upload_folder, self.company_logo_filename)
except RuntimeError:
# current_app not available (e.g., during testing or initialization)
# Fallback to a relative path
return os.path.join("app", "static", "uploads", "logos", self.company_logo_filename)
def has_logo(self):
"""Check if company has a logo uploaded"""
if not self.company_logo_filename:
return False
logo_path = self.get_logo_path()
return logo_path and os.path.exists(logo_path)
def get_mail_config(self):
"""Get email configuration, preferring database settings over environment variables"""
if self.mail_enabled and self.mail_server:
return {
"MAIL_SERVER": self.mail_server,
"MAIL_PORT": self.mail_port or 587,
"MAIL_USE_TLS": self.mail_use_tls if self.mail_use_tls is not None else True,
"MAIL_USE_SSL": self.mail_use_ssl if self.mail_use_ssl is not None else False,
"MAIL_USERNAME": self.mail_username or None,
"MAIL_PASSWORD": (decrypt_if_needed(self.mail_password) or None),
"MAIL_DEFAULT_SENDER": self.mail_default_sender or "noreply@timetracker.local",
}
return None
def get_ai_config(self, *, include_secrets: bool = False) -> dict:
"""Get AI helper configuration, preferring database settings over environment/app config.
By default, secrets are not returned (UI-safe). Use include_secrets=True for server runtime calls.
"""
from flask import current_app
def cfg(name, default=None):
try:
return current_app.config.get(name, default)
except RuntimeError:
return getattr(Config, name, default)
provider = (getattr(self, "ai_provider", "") or cfg("AI_PROVIDER", "ollama") or "ollama").strip().lower()
base_url = (getattr(self, "ai_base_url", "") or cfg("AI_BASE_URL", "http://127.0.0.1:11434") or "").strip()
model = (getattr(self, "ai_model", "") or cfg("AI_MODEL", "llama3.1") or "").strip()
timeout = getattr(self, "ai_timeout_seconds", None) or cfg("AI_TIMEOUT_SECONDS", 30)
context_limit = getattr(self, "ai_context_limit", None) or cfg("AI_CONTEXT_LIMIT", 40)
system_prompt = (getattr(self, "ai_system_prompt", "") or cfg("AI_SYSTEM_PROMPT", "") or "").strip()
api_key_raw = (getattr(self, "ai_api_key", "") or cfg("AI_API_KEY", "") or "").strip()
api_key = decrypt_if_needed(api_key_raw) if api_key_raw else ""
enabled = getattr(self, "ai_enabled", None)
if enabled is None:
enabled = bool(cfg("AI_ENABLED", False))
try:
timeout = max(1, int(timeout))
except (TypeError, ValueError):
timeout = 30
try:
context_limit = max(5, int(context_limit))
except (TypeError, ValueError):
context_limit = 40
return {
"enabled": bool(enabled),
"provider": provider if provider in {"ollama", "openai_compatible"} else "ollama",
"base_url": base_url.rstrip("/"),
"model": model,
"api_key": api_key if include_secrets else "",
"api_key_set": bool(api_key_raw),
"timeout_seconds": timeout,
"context_limit": context_limit,
"system_prompt": system_prompt,
}
def get_integration_credentials(self, provider: str, *, include_secrets: bool = True) -> dict:
"""Get integration OAuth credentials, preferring database settings over environment variables.
Args:
provider: One of 'jira', 'slack', 'github', 'google_calendar', 'outlook_calendar',
'microsoft_teams', 'asana', 'trello', 'gitlab', 'quickbooks', 'xero'
Returns:
dict with credentials (varies by provider):
- Standard OAuth: 'client_id', 'client_secret'
- Microsoft: 'client_id', 'client_secret', 'tenant_id'
- Trello: 'api_key', 'api_secret'
- GitLab: 'client_id', 'client_secret', 'instance_url'
"""
import os
if provider == "jira":
client_id = self.jira_client_id or os.getenv("JIRA_CLIENT_ID", "")
client_secret_raw = self.jira_client_secret or os.getenv("JIRA_CLIENT_SECRET", "")
client_secret = decrypt_if_needed(client_secret_raw) if include_secrets else ""
return {"client_id": client_id, "client_secret": client_secret}
elif provider == "slack":
client_id = self.slack_client_id or os.getenv("SLACK_CLIENT_ID", "")
client_secret_raw = self.slack_client_secret or os.getenv("SLACK_CLIENT_SECRET", "")
client_secret = decrypt_if_needed(client_secret_raw) if include_secrets else ""
return {"client_id": client_id, "client_secret": client_secret}
elif provider == "github":
client_id = self.github_client_id or os.getenv("GITHUB_CLIENT_ID", "")
client_secret_raw = self.github_client_secret or os.getenv("GITHUB_CLIENT_SECRET", "")
client_secret = decrypt_if_needed(client_secret_raw) if include_secrets else ""
return {"client_id": client_id, "client_secret": client_secret}
elif provider == "google_calendar":
client_id = getattr(self, "google_calendar_client_id", "") or os.getenv("GOOGLE_CLIENT_ID", "")
client_secret_raw = getattr(self, "google_calendar_client_secret", "") or os.getenv("GOOGLE_CLIENT_SECRET", "")
client_secret = decrypt_if_needed(client_secret_raw) if include_secrets else ""
return {"client_id": client_id, "client_secret": client_secret}
elif provider == "outlook_calendar":
client_id = getattr(self, "outlook_calendar_client_id", "") or os.getenv("OUTLOOK_CLIENT_ID", "")
client_secret_raw = getattr(self, "outlook_calendar_client_secret", "") or os.getenv(
"OUTLOOK_CLIENT_SECRET", ""
)
tenant_id = getattr(self, "outlook_calendar_tenant_id", "") or os.getenv("OUTLOOK_TENANT_ID", "")
client_secret = decrypt_if_needed(client_secret_raw) if include_secrets else ""
return {"client_id": client_id, "client_secret": client_secret, "tenant_id": tenant_id}
elif provider == "microsoft_teams":
client_id = getattr(self, "microsoft_teams_client_id", "") or os.getenv("MICROSOFT_TEAMS_CLIENT_ID", "")
client_secret_raw = getattr(self, "microsoft_teams_client_secret", "") or os.getenv(
"MICROSOFT_TEAMS_CLIENT_SECRET", ""
)
tenant_id = getattr(self, "microsoft_teams_tenant_id", "") or os.getenv("MICROSOFT_TEAMS_TENANT_ID", "")
client_secret = decrypt_if_needed(client_secret_raw) if include_secrets else ""
return {"client_id": client_id, "client_secret": client_secret, "tenant_id": tenant_id}
elif provider == "asana":
client_id = getattr(self, "asana_client_id", "") or os.getenv("ASANA_CLIENT_ID", "")
client_secret_raw = getattr(self, "asana_client_secret", "") or os.getenv("ASANA_CLIENT_SECRET", "")
client_secret = decrypt_if_needed(client_secret_raw) if include_secrets else ""
return {"client_id": client_id, "client_secret": client_secret}
elif provider == "trello":
api_key = getattr(self, "trello_api_key", "") or os.getenv("TRELLO_API_KEY", "")
api_secret_raw = getattr(self, "trello_api_secret", "") or os.getenv("TRELLO_API_SECRET", "")
api_secret = decrypt_if_needed(api_secret_raw) if include_secrets else ""
return {"api_key": api_key, "api_secret": api_secret}
elif provider == "gitlab":
client_id = getattr(self, "gitlab_client_id", "") or os.getenv("GITLAB_CLIENT_ID", "")
client_secret_raw = getattr(self, "gitlab_client_secret", "") or os.getenv("GITLAB_CLIENT_SECRET", "")
instance_url = getattr(self, "gitlab_instance_url", "") or os.getenv(
"GITLAB_INSTANCE_URL", "https://gitlab.com"
)
client_secret = decrypt_if_needed(client_secret_raw) if include_secrets else ""
return {"client_id": client_id, "client_secret": client_secret, "instance_url": instance_url}
elif provider == "quickbooks":
client_id = getattr(self, "quickbooks_client_id", "") or os.getenv("QUICKBOOKS_CLIENT_ID", "")
client_secret_raw = getattr(self, "quickbooks_client_secret", "") or os.getenv("QUICKBOOKS_CLIENT_SECRET", "")
client_secret = decrypt_if_needed(client_secret_raw) if include_secrets else ""
return {"client_id": client_id, "client_secret": client_secret}
elif provider == "xero":
client_id = getattr(self, "xero_client_id", "") or os.getenv("XERO_CLIENT_ID", "")
client_secret_raw = getattr(self, "xero_client_secret", "") or os.getenv("XERO_CLIENT_SECRET", "")
client_secret = decrypt_if_needed(client_secret_raw) if include_secrets else ""
return {"client_id": client_id, "client_secret": client_secret}
else:
return {}
def to_dict(self):
"""Convert settings to dictionary for API responses"""
return {
"id": self.id,
"timezone": self.timezone,
"date_format": self.date_format,
"time_format": self.time_format,
"currency": self.currency,
"rounding_minutes": self.rounding_minutes,
"single_active_timer": self.single_active_timer,
"allow_self_register": self.allow_self_register,
"idle_timeout_minutes": self.idle_timeout_minutes,
"backup_retention_days": self.backup_retention_days,
"backup_time": self.backup_time,
"export_delimiter": self.export_delimiter,
"company_name": self.company_name,
"company_address": self.company_address,
"company_email": self.company_email,
"company_phone": self.company_phone,
"company_website": self.company_website,
"company_logo_filename": self.company_logo_filename,
"company_logo_url": self.get_logo_url(),
"has_logo": self.has_logo(),
"company_tax_id": self.company_tax_id,
"company_bank_info": self.company_bank_info,
"invoice_prefix": self.invoice_prefix,
"invoice_number_pattern": self.invoice_number_pattern,
"invoice_start_number": self.invoice_start_number,
"invoice_terms": self.invoice_terms,
"invoice_notes": self.invoice_notes,
"peppol_enabled": self.peppol_enabled,
"peppol_sender_endpoint_id": getattr(self, "peppol_sender_endpoint_id", "") or "",
"peppol_sender_scheme_id": getattr(self, "peppol_sender_scheme_id", "") or "",
"peppol_sender_country": getattr(self, "peppol_sender_country", "") or "",
"peppol_access_point_url": getattr(self, "peppol_access_point_url", "") or "",
"peppol_access_point_token_set": bool(getattr(self, "peppol_access_point_token", "")),
"peppol_access_point_timeout": getattr(self, "peppol_access_point_timeout", None),
"peppol_provider": getattr(self, "peppol_provider", "") or "",
"peppol_transport_mode": getattr(self, "peppol_transport_mode", "") or "generic",
"peppol_sml_url": getattr(self, "peppol_sml_url", "") or "",
"peppol_native_cert_path": getattr(self, "peppol_native_cert_path", "") or "",
"peppol_native_key_path": getattr(self, "peppol_native_key_path", "") or "",
"invoices_peppol_compliant": getattr(self, "invoices_peppol_compliant", False),
"invoices_zugferd_pdf": getattr(self, "invoices_zugferd_pdf", False),
"invoices_pdfa3_compliant": getattr(self, "invoices_pdfa3_compliant", False),
"invoices_validate_export": getattr(self, "invoices_validate_export", False),
"invoices_verapdf_path": getattr(self, "invoices_verapdf_path", "") or "",
"invoice_pdf_template_html": self.invoice_pdf_template_html,
"invoice_pdf_template_css": self.invoice_pdf_template_css,
"invoice_pdf_design_json": self.invoice_pdf_design_json,
"allow_analytics": self.allow_analytics,
"disabled_module_ids": (self.disabled_module_ids if self.disabled_module_ids is not None else []),
"locked_client_id": getattr(self, "locked_client_id", None),
"mail_enabled": self.mail_enabled,
"mail_server": self.mail_server,
"mail_port": self.mail_port,
"mail_use_tls": self.mail_use_tls,
"mail_use_ssl": self.mail_use_ssl,
"mail_username": self.mail_username,
"mail_password_set": bool(self.mail_password), # Don't expose actual password
"mail_default_sender": self.mail_default_sender,
"mail_test_recipient": getattr(self, "mail_test_recipient", "") or "",
"jira_client_id": self.jira_client_id or "",
"jira_client_secret_set": bool(self.jira_client_secret), # Don't expose actual secret
"slack_client_id": self.slack_client_id or "",
"slack_client_secret_set": bool(self.slack_client_secret), # Don't expose actual secret
"github_client_id": self.github_client_id or "",
"github_client_secret_set": bool(self.github_client_secret), # Don't expose actual secret
"google_calendar_client_id": getattr(self, "google_calendar_client_id", "") or "",
"google_calendar_client_secret_set": bool(getattr(self, "google_calendar_client_secret", "")),
"outlook_calendar_client_id": getattr(self, "outlook_calendar_client_id", "") or "",
"outlook_calendar_client_secret_set": bool(getattr(self, "outlook_calendar_client_secret", "")),
"outlook_calendar_tenant_id": getattr(self, "outlook_calendar_tenant_id", "") or "",
"microsoft_teams_client_id": getattr(self, "microsoft_teams_client_id", "") or "",
"microsoft_teams_client_secret_set": bool(getattr(self, "microsoft_teams_client_secret", "")),
"microsoft_teams_tenant_id": getattr(self, "microsoft_teams_tenant_id", "") or "",
"asana_client_id": getattr(self, "asana_client_id", "") or "",
"asana_client_secret_set": bool(getattr(self, "asana_client_secret", "")),
"trello_api_key": getattr(self, "trello_api_key", "") or "",
"trello_api_secret_set": bool(getattr(self, "trello_api_secret", "")),
"gitlab_client_id": getattr(self, "gitlab_client_id", "") or "",
"gitlab_client_secret_set": bool(getattr(self, "gitlab_client_secret", "")),
"gitlab_instance_url": getattr(self, "gitlab_instance_url", "") or "",
"quickbooks_client_id": getattr(self, "quickbooks_client_id", "") or "",
"quickbooks_client_secret_set": bool(getattr(self, "quickbooks_client_secret", "")),
"xero_client_id": getattr(self, "xero_client_id", "") or "",
"xero_client_secret_set": bool(getattr(self, "xero_client_secret", "")),
"time_entry_require_task": getattr(self, "time_entry_require_task", False),
"time_entry_require_description": getattr(self, "time_entry_require_description", False),
"time_entry_description_min_length": getattr(self, "time_entry_description_min_length", 20),
"default_daily_working_hours": float(getattr(self, "default_daily_working_hours", 8.0) or 8.0),
"ai_enabled": getattr(self, "ai_enabled", None),
"ai_provider": getattr(self, "ai_provider", "") or "",
"ai_base_url": getattr(self, "ai_base_url", "") or "",
"ai_model": getattr(self, "ai_model", "") or "",
"ai_api_key_set": bool(getattr(self, "ai_api_key", "")),
"ai_timeout_seconds": getattr(self, "ai_timeout_seconds", None),
"ai_context_limit": getattr(self, "ai_context_limit", None),
"ai_system_prompt": getattr(self, "ai_system_prompt", "") or "",
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
_SECRET_FIELDS = (
"mail_password",
"peppol_access_point_token",
"ai_api_key",
"jira_client_secret",
"slack_client_secret",
"github_client_secret",
"google_calendar_client_secret",
"outlook_calendar_client_secret",
"microsoft_teams_client_secret",
"asana_client_secret",
"trello_api_secret",
"gitlab_client_secret",
"quickbooks_client_secret",
"xero_client_secret",
)
def set_secret(self, field: str, value: str) -> None:
if field not in self._SECRET_FIELDS:
raise ValueError("unsupported secret field")
value = (value or "").strip()
if not value:
setattr(self, field, "")
return
if secrets_encryption_configured():
setattr(self, field, encrypt_if_possible(value))
else:
# Best-effort fallback; still store (legacy behavior) but mark clearly in logs by leaving plaintext.
setattr(self, field, value)
def get_secret(self, field: str) -> str:
if field not in self._SECRET_FIELDS:
raise ValueError("unsupported secret field")
return decrypt_if_needed(getattr(self, field, "") or "")
def _encrypt_secrets_if_needed(self) -> bool:
"""
One-time best-effort migration: if a secret is stored in plaintext and encryption is configured,
rewrite it encrypted. Returns True if any field changed.
"""
if not secrets_encryption_configured():
return False
changed = False
for f in self._SECRET_FIELDS:
raw = (getattr(self, f, "") or "").strip()
if raw and not raw.startswith("enc:v1:"):
setattr(self, f, encrypt_if_possible(raw))
changed = True
return changed
@classmethod
def get_settings(cls):
"""Get the singleton settings instance, creating it if it doesn't exist.
When creating a new Settings instance, it will be initialized from
environment variables (.env file) as initial values.
"""
try:
settings = cls.query.first()
# #region agent log
try:
import json
log_data = {
"location": "settings.py:422",
"message": "Settings query result",
"data": {
"settings_is_none": settings is None,
"settings_has_id": settings is not None and hasattr(settings, "id") and settings.id is not None,
"invoice_prefix": getattr(settings, "invoice_prefix", "MISSING") if settings else "N/A",
"invoice_start_number": (
getattr(settings, "invoice_start_number", "MISSING") if settings else "N/A"
),
},
"timestamp": int(datetime.utcnow().timestamp() * 1000),
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "D",
}
log_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".cursor", "debug.log"
)
with open(log_path, "a", encoding="utf-8") as f:
f.write(json.dumps(log_data) + "\n")
except (OSError, IOError, TypeError, ValueError):
pass
# #endregion
if settings:
return settings
except Exception as e:
# Handle case where table or columns don't exist yet (migration not run)
# Check if it's a table/column error - if so, it's expected during migrations
error_str = str(e)
# Also check the underlying exception if it's a SQLAlchemy exception
underlying_error = ""
if hasattr(e, "orig"):
underlying_error = str(e.orig)
elif hasattr(e, "__cause__") and e.__cause__:
underlying_error = str(e.__cause__)
combined_error = f"{error_str} {underlying_error}".lower()
is_schema_error = (
"undefinedcolumn" in combined_error
or "does not exist" in combined_error
or "no such column" in combined_error
or "no such table" in combined_error
or ("relation" in combined_error and "does not exist" in combined_error)
or "operationalerror" in combined_error
and ("no such table" in combined_error or "does not exist" in combined_error)
)
import logging
logger = logging.getLogger(__name__)
if is_schema_error:
# This is expected during migrations when schema is incomplete
# Only log at debug level to avoid cluttering logs
logger.debug(
f"Settings table not available (migration may be pending): {error_str.split('LINE')[0] if 'LINE' in error_str else error_str}"
)
else:
# Other errors should be logged as warnings
logger.warning(f"Could not query settings: {e}")
# Rollback the failed transaction
try:
db.session.rollback()
except Exception:
pass
# Return fallback instance with defaults
return cls()
# Avoid performing session writes during flush/commit phases.
# When called from default column factories or listeners during flush,
# SQLAlchemy may be in the middle of a flush. Writing here would raise
# SAWarnings/ResourceClosedError. Skip add+commit and return a transient
# instance; the persistent row can be created later by init or admin flows.
try:
if getattr(_creating_settings, "active", False):
return cls()
if _session_in_flush(db.session):
return cls()
try:
_creating_settings.active = True
# Create new settings instance initialized from environment variables
settings = cls()
# Initialize from environment variables (.env file)
cls._initialize_from_env(settings)
db.session.add(settings)
db.session.commit()
return settings
finally:
_creating_settings.active = False
except Exception:
# If anything goes wrong creating the persistent row, rollback and
# fall back to an in-memory Settings instance.
try:
db.session.rollback()
except Exception:
# Ignore rollback failures to avoid masking original contexts
pass
# Fallback: return a non-persisted Settings instance
import logging
logging.getLogger(__name__).warning(
"Returning transient in-memory Settings instance (database row missing or creation failed). "
"Check database connectivity and migrations."
)
return cls()
@classmethod
def update_settings(cls, **kwargs):
"""Update settings with new values"""
settings = cls.get_settings()
for key, value in kwargs.items():
if hasattr(settings, key):
setattr(settings, key, value)
settings.updated_at = datetime.utcnow()
db.session.commit()
# Best-effort migration of plaintext secrets to encrypted-at-rest when key is configured.
try:
if settings and settings._encrypt_secrets_if_needed() and not _session_in_flush(db.session):
db.session.add(settings)
db.session.commit()
except Exception:
try:
db.session.rollback()
except Exception:
pass
return settings
@classmethod
def get_system_instance_id(cls):
"""Return stable per-installation UUID; create and persist if missing."""
import uuid
settings = cls.get_settings()
if not getattr(settings, "id", None):
return None
if getattr(settings, "system_instance_id", None):
return settings.system_instance_id
try:
if _session_in_flush(db.session):
return None
settings.system_instance_id = str(uuid.uuid4())
db.session.commit()
return settings.system_instance_id
except Exception:
try:
db.session.rollback()
except Exception:
pass
return None
@classmethod
def _initialize_from_env(cls, settings_instance):
"""
Initialize Settings instance from environment variables (.env file).
This is called when creating a new Settings instance to use .env values
as initial startup values.
Args:
settings_instance: Settings instance to initialize
"""
# Map environment variable names to Settings model attributes
env_mapping = {
"TZ": "timezone",
"DATE_FORMAT": "date_format",
"TIME_FORMAT": "time_format",
"CURRENCY": "currency",
"ROUNDING_MINUTES": "rounding_minutes",
"SINGLE_ACTIVE_TIMER": "single_active_timer",
"ALLOW_SELF_REGISTER": "allow_self_register",
"IDLE_TIMEOUT_MINUTES": "idle_timeout_minutes",
"BACKUP_RETENTION_DAYS": "backup_retention_days",
"BACKUP_TIME": "backup_time",
"DEFAULT_DAILY_WORKING_HOURS": "default_daily_working_hours",
"AI_ENABLED": "ai_enabled",
"AI_PROVIDER": "ai_provider",
"AI_BASE_URL": "ai_base_url",
"AI_MODEL": "ai_model",
"AI_API_KEY": "ai_api_key",
"AI_TIMEOUT_SECONDS": "ai_timeout_seconds",
"AI_CONTEXT_LIMIT": "ai_context_limit",
"AI_SYSTEM_PROMPT": "ai_system_prompt",
}
for env_var, attr_name in env_mapping.items():
if hasattr(settings_instance, attr_name):
env_value = os.getenv(env_var)
if env_value is not None:
# Convert value types based on attribute type
current_value = getattr(settings_instance, attr_name)
if isinstance(current_value, bool):
# Handle boolean values
setattr(settings_instance, attr_name, env_value.lower() == "true")
elif isinstance(current_value, int):
# Handle integer values
try:
setattr(settings_instance, attr_name, int(env_value))
except (ValueError, TypeError):
pass # Keep default if conversion fails
elif isinstance(current_value, float):
try:
setattr(settings_instance, attr_name, float(env_value))
except (ValueError, TypeError):
pass
else:
# Handle string values
setattr(settings_instance, attr_name, env_value)
@classmethod
def sync_from_env(cls):
"""
Sync Settings from environment variables (.env file) for fields that haven't
been customized in the WebUI. This is useful for initializing Settings on startup
or when new environment variables are added.
Only updates fields that are still at their default values (not customized via WebUI).
"""
try:
settings = cls.get_settings()
if not settings or not hasattr(settings, "id"):
# Settings doesn't exist in DB yet, get_settings will create it
return
# Only sync if Settings was just created (id is None means it's a new instance)
# For existing Settings, we don't overwrite WebUI changes
# This method is mainly for ensuring new Settings get initialized from .env
if settings.id is None:
cls._initialize_from_env(settings)
if hasattr(db.session, "add"):
db.session.add(settings)
db.session.commit()
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Could not sync Settings from environment: {e}")
try:
db.session.rollback()
except Exception:
pass