mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 12:50:11 -05:00
ea913c6c4b
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.
913 lines
49 KiB
Python
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
|