#!/usr/bin/env python3 import json import os from typing import Any, List, Optional, Union from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") database_url: str redis_url: str # Database connection-pool tuning (ignored for SQLite, which uses NullPool). db_pool_size: int = Field( default=10, description="Number of persistent connections kept in the pool per worker process.", ) db_max_overflow: int = Field( default=20, description="Additional connections allowed beyond db_pool_size under burst load.", ) db_pool_timeout: int = Field( default=30, description="Seconds to wait for a connection from the pool before raising a TimeoutError.", ) db_pool_recycle: int = Field( default=1800, description="Recycle (close and reopen) connections after this many seconds to avoid stale connections.", ) openai_api_key: str openai_base_url: str = "https://api.openai.com/v1" # Default to OpenAI's endpoint openai_model: str = "gpt-4o-mini" # Default model # AI provider abstraction layer # Supported values: openai, azure, anthropic, gemini, ollama, openrouter, litellm ai_provider: str = "openai" # Override model for any provider; falls back to openai_model when not set ai_model: Optional[str] = None # Anthropic Claude settings (used when ai_provider="anthropic") anthropic_api_key: Optional[str] = None # Google Gemini settings (used when ai_provider="gemini") gemini_api_key: Optional[str] = None # Ollama local LLM settings (used when ai_provider="ollama") ollama_base_url: str = "http://localhost:11434" # OpenRouter settings (used when ai_provider="openrouter") openrouter_api_key: Optional[str] = None openrouter_base_url: str = "https://openrouter.ai/api/v1" # Portkey AI gateway settings (used when ai_provider="portkey") # See https://portkey.ai for setup instructions portkey_api_key: Optional[str] = None portkey_virtual_key: Optional[str] = None # Routes to a specific provider via Portkey vault portkey_config: Optional[str] = None # Portkey Config ID for advanced routing rules portkey_base_url: str = "https://api.portkey.ai/v1" # Azure OpenAI API version (used when ai_provider="azure") azure_openai_api_version: str = "2024-02-01" workdir: str debug: bool = False # Default to False # Logging level for the application. Accepts standard Python level names: # DEBUG, INFO, WARNING, ERROR, CRITICAL. When *debug* is True and # *log_level* has not been explicitly set, the effective level is forced to # DEBUG so that all ``logger.debug()`` calls produce output. log_level: str = Field( default="INFO", description=( "Python logging level for the application root logger. " "Accepts: DEBUG, INFO, WARNING, ERROR, CRITICAL. " "When DEBUG=True and LOG_LEVEL is not explicitly set, " "the effective level is automatically lowered to DEBUG." ), ) # Log output format. ``text`` is the human-readable default. # ``json`` emits one JSON object per line, ideal for log collectors # (Promtail, Fluentd, Filebeat, Datadog agent) and SIEM ingestion. log_format: str = Field( default="text", description=( "Log output format: 'text' (human-readable, default) or " "'json' (structured JSON lines for SIEM / log aggregation)." ), ) # Optional syslog forwarding for application logs (not just audit events). # When enabled, a Python SysLogHandler is added to the root logger so that # every log message is also sent to the configured syslog receiver. log_syslog_enabled: bool = Field( default=False, description="Forward application logs to a syslog receiver in addition to stdout.", ) log_syslog_host: str = Field( default="localhost", description="Hostname or IP of the syslog receiver for application logs.", ) log_syslog_port: int = Field( default=514, description="Port of the syslog receiver for application logs.", ) log_syslog_protocol: str = Field( default="udp", description="Protocol for syslog transport: 'udp' or 'tcp'.", ) # Making Dropbox optional dropbox_enabled: bool = Field( default=True, description="Enable Dropbox as an upload destination. Set to False to disable uploads even when credentials are configured.", ) dropbox_app_key: Optional[str] = None dropbox_app_secret: Optional[str] = None dropbox_folder: Optional[str] = None dropbox_refresh_token: Optional[str] = None dropbox_allow_global_credentials_for_integrations: bool = Field( default=False, description=( "When True, users may authorize their personal Dropbox integrations using the global " "DROPBOX_APP_KEY / DROPBOX_APP_SECRET credentials configured by the admin, without " "needing to create their own Dropbox app. The Dropbox OAuth flow is initiated " "server-side so the app secret is never exposed to the browser. " "Default: False (each user must supply their own app credentials)." ), ) # Making Nextcloud optional nextcloud_enabled: bool = Field( default=True, description="Enable Nextcloud as an upload destination. Set to False to disable uploads even when credentials are configured.", ) nextcloud_upload_url: Optional[str] = None nextcloud_username: Optional[str] = None nextcloud_password: Optional[str] = None nextcloud_folder: Optional[str] = None # Making Paperless optional paperless_enabled: bool = Field( default=True, description="Enable Paperless-ngx as an upload destination. Set to False to disable uploads even when credentials are configured.", ) paperless_ngx_api_token: Optional[str] = None paperless_host: Optional[str] = None paperless_custom_field_absender: Optional[str] = None # Name of the "absender" custom field in Paperless # JSON mapping of metadata field names to Paperless custom field names # Example: {"absender": "Sender", "empfaenger": "Recipient", # "language": "Language", "correspondent": "Correspondent"} paperless_custom_fields_mapping: Optional[str] = None azure_ai_key: str azure_region: str azure_endpoint: str gotenberg_url: str # --------------------------------------------------------------------------- # OCR provider settings # --------------------------------------------------------------------------- # Comma-separated list of OCR engines to use. # Supported values: azure, tesseract, easyocr, mistral, google_docai, aws_textract # When multiple engines are listed all are run and results are merged. # Example: OCR_PROVIDERS=azure,tesseract ocr_providers: str = "azure" # Strategy for merging results from multiple OCR providers. # - ai_merge : Ask the AI model to produce the best merged text (default). # - longest : Return the result with the most characters. # - primary : Return only the first provider's result (no merging). ocr_merge_strategy: str = "ai_merge" # Tesseract OCR settings (used when "tesseract" is in OCR_PROVIDERS) tesseract_cmd: Optional[str] = None # Path to tesseract binary (e.g. /usr/bin/tesseract) tesseract_language: str = "eng+deu" # Tesseract language code(s), e.g. "eng" or "eng+deu" # EasyOCR settings (used when "easyocr" is in OCR_PROVIDERS) easyocr_languages: str = "en,de" # Comma-separated language codes, e.g. "en,de,fr" easyocr_gpu: bool = False # Enable GPU acceleration for EasyOCR # Mistral OCR settings (used when "mistral" is in OCR_PROVIDERS) mistral_api_key: Optional[str] = None mistral_ocr_model: str = "mistral-ocr-latest" # Google Cloud Document AI settings (used when "google_docai" is in OCR_PROVIDERS) # Falls back to google_drive_credentials_json for service account credentials. google_docai_credentials_json: Optional[str] = None google_docai_project_id: Optional[str] = None google_docai_processor_id: Optional[str] = None google_docai_location: str = "us" # Processor location, e.g. "us" or "eu" external_hostname: str = "localhost" # Default to localhost public_base_url: Optional[str] = Field( default=None, description=( "The full public base URL of the application, including scheme " "(e.g., 'https://docuelevate.example.com'). " "When set, this overrides the auto-detected URL for OAuth redirect URIs. " "This is required when the application is behind a reverse proxy that does " "not forward X-Forwarded-Proto headers correctly." ), ) # --------------------------------------------------------------------------- # Document Translation Settings # --------------------------------------------------------------------------- # Default target language for automatic document translation (ISO 639-1 code). # After OCR / metadata extraction, if the detected document language differs # from this value the system translates the extracted text into this language # and stores it alongside the original. Other language translations are # generated on the fly via the AI provider and are NOT persisted. # Per-user overrides are stored in UserProfile.default_document_language. default_document_language: str = Field( default="en", description=( "ISO 639-1 language code for the default translation target " "(e.g. 'en', 'de', 'fr'). Documents whose detected language " "differs are automatically translated into this language after " "processing. Default: 'en' (English)." ), ) # Authentication settings auth_enabled: bool = True # Default to enabled admin_username: Optional[str] = None admin_password: Optional[str] = None session_secret: Optional[str] = None session_lifetime_days: int = Field( default=30, description=( "Session lifetime in days. Common values: 30, 60, 90. " "Determines how long a user stays logged in before being required to re-authenticate. " "Applies to both browser sessions and the session cookie max_age." ), ) session_lifetime_custom_days: int | None = Field( default=None, description=( "Override session_lifetime_days with a custom value. " "When set, this takes precedence over session_lifetime_days. " "Useful for admin-configured non-standard durations." ), ) qr_login_enabled: bool = Field( default=True, description="Enable QR code-based login for mobile device authentication (default: True).", ) qr_login_challenge_ttl_seconds: int = Field( default=120, description="Time-to-live in seconds for QR login challenges (default: 2 minutes).", ) admin_group_name: str = "admin" # Multi-user settings multi_user_enabled: bool = Field( default=False, description=( "Enable multi-user mode with individual document spaces per user. " "When enabled, each authenticated user sees only their own documents, " "uploads, and search results. Shared settings (AI, OCR) remain global. " "Requires auth_enabled=True. Default: False (single-user/shared mode)." ), ) default_daily_upload_limit: int = Field( default=0, description=( "Default maximum number of document uploads allowed per user per day " "in multi-user mode. Set to 0 for unlimited. " "Individual user limits can override this default. Default: 0 (unlimited)." ), ) unowned_docs_visible_to_all: bool = Field( default=True, description=( "In multi-user mode, controls whether documents without an owner (owner_id is NULL) " "are visible to all authenticated users. When True, unowned documents appear in every " "user's file list alongside their own files. When False, only admins can see unowned " "documents. Default: True." ), ) default_owner_id: Optional[str] = Field( default=None, description=( "When set, automatically assigns this owner ID to newly ingested documents that would " "otherwise have no owner (e.g. documents from IMAP, API without session, or legacy imports). " "Use the admin /api/files/assign-owner endpoint to bulk-assign existing unclaimed documents. " "Default: None (documents remain unowned until claimed)." ), ) subscription_overage_percent: int = Field( default=20, ge=0, le=200, description=( "Soft-limit overage buffer in percent (0–200). The announced monthly quota is " "increased by this percentage for actual enforcement. E.g. 20 means a 150-doc/month " "plan enforces at 180 docs (150 × 1.20). Set 0 to enforce exactly at the announced " "limit. Per-plan overage_percent (set in Plan Designer) overrides this global default. " "Default: 20." ), ) # Authentik / Generic OIDC authentik_client_id: Optional[str] = None authentik_client_secret: Optional[str] = None authentik_config_url: Optional[str] = None oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider sso_auto_login: bool = Field( default=False, description=( "Automatically redirect to SSO login when authentication is required. " "When enabled, users are sent directly to the SSO provider instead of " "seeing the login page. Only effective when OIDC is configured." ), ) # Keycloak SSO social_auth_keycloak_enabled: bool = False social_auth_keycloak_client_id: Optional[str] = None social_auth_keycloak_client_secret: Optional[str] = None social_auth_keycloak_server_url: Optional[str] = None social_auth_keycloak_realm: Optional[str] = None # Generic OAuth2 SSO social_auth_generic_oauth2_enabled: bool = False social_auth_generic_oauth2_client_id: Optional[str] = None social_auth_generic_oauth2_client_secret: Optional[str] = None social_auth_generic_oauth2_authorize_url: Optional[str] = None social_auth_generic_oauth2_token_url: Optional[str] = None social_auth_generic_oauth2_userinfo_url: Optional[str] = None social_auth_generic_oauth2_scope: str = "openid profile email" social_auth_generic_oauth2_name: str = "OAuth2" # SAML2 SSO social_auth_saml2_enabled: bool = False social_auth_saml2_entity_id: Optional[str] = None social_auth_saml2_sso_url: Optional[str] = None social_auth_saml2_certificate: Optional[str] = None social_auth_saml2_name: str = "SAML2" # Social Login Providers # Google OAuth2 social_auth_google_enabled: bool = False social_auth_google_client_id: Optional[str] = None social_auth_google_client_secret: Optional[str] = None social_auth_google_use_global_credentials: bool = Field( default=False, description=( "When True, Google social login uses the global GOOGLE_DRIVE_CLIENT_ID / " "GOOGLE_DRIVE_CLIENT_SECRET credentials (the Google Drive OAuth integration credentials) " "instead of requiring separate SOCIAL_AUTH_GOOGLE_CLIENT_ID / " "SOCIAL_AUTH_GOOGLE_CLIENT_SECRET values. " "Requires SOCIAL_AUTH_GOOGLE_ENABLED=True and the global Google Drive OAuth credentials to be set. " "Default: False." ), ) # Microsoft OAuth2 (Azure AD / Microsoft Entra ID) social_auth_microsoft_enabled: bool = False social_auth_microsoft_client_id: Optional[str] = None social_auth_microsoft_client_secret: Optional[str] = None social_auth_microsoft_tenant: str = Field( default="common", description=( "Azure AD tenant ID or one of 'common', 'organizations', 'consumers'. " "Use 'common' to allow any Microsoft account and any Azure AD org. " "Use a specific tenant ID (GUID) to restrict to a single organization. " "Default: common." ), ) social_auth_microsoft_use_global_credentials: bool = Field( default=False, description=( "When True, Microsoft social login uses the global ONEDRIVE_CLIENT_ID / " "ONEDRIVE_CLIENT_SECRET credentials (the OneDrive integration credentials) " "instead of requiring separate SOCIAL_AUTH_MICROSOFT_CLIENT_ID / " "SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET values. " "Requires SOCIAL_AUTH_MICROSOFT_ENABLED=True and the global OneDrive credentials to be set. " "Default: False." ), ) # Apple Sign-In social_auth_apple_enabled: bool = False social_auth_apple_client_id: Optional[str] = None social_auth_apple_team_id: Optional[str] = None social_auth_apple_key_id: Optional[str] = None social_auth_apple_private_key: Optional[str] = None # Dropbox OAuth2 social_auth_dropbox_enabled: bool = False social_auth_dropbox_client_id: Optional[str] = None social_auth_dropbox_client_secret: Optional[str] = None social_auth_dropbox_use_global_credentials: bool = Field( default=False, description=( "When True, Dropbox social login uses the global DROPBOX_APP_KEY / DROPBOX_APP_SECRET " "credentials (the storage integration credentials) instead of requiring separate " "SOCIAL_AUTH_DROPBOX_CLIENT_ID / SOCIAL_AUTH_DROPBOX_CLIENT_SECRET values. " "Requires SOCIAL_AUTH_DROPBOX_ENABLED=True and the global Dropbox app credentials to be set. " "Default: False." ), ) # GitHub OAuth2 social_auth_github_enabled: bool = False social_auth_github_client_id: Optional[str] = None social_auth_github_client_secret: Optional[str] = None # Local user signup allow_local_signup: bool = Field( default=False, description=( "Allow users to self-register with email and password. " "Has no effect unless MULTI_USER_ENABLED is also True. " "Requires SMTP to be configured so verification emails can be sent. " "Default: False (registration disabled — admin creates users manually)." ), ) # Stripe billing stripe_secret_key: Optional[str] = None stripe_publishable_key: Optional[str] = None stripe_webhook_secret: Optional[str] = None stripe_success_url: Optional[str] = None # e.g. https://app.example.com/billing/success stripe_cancel_url: Optional[str] = None # e.g. https://app.example.com/pricing # --------------------------------------------------------------------------- # Watch Folder Ingestion # --------------------------------------------------------------------------- # Local filesystem watch folders (comma-separated list of absolute paths). # DocuElevate will poll each path for new files and automatically ingest them. # Works with any mounted path, including SMB/CIFS (via system mount), NFS, etc. # Example: /watchfolders/scanner,/mnt/shared/inbox watch_folders: Optional[str] = Field( default=None, description=( "Comma-separated list of local filesystem paths (absolute) that DocuElevate will " "poll for new files to ingest. Each file found is enqueued for document processing. " "Works with any mounted path including SMB/CIFS (mounted via system) and NFS. " "Example: /watchfolders/scanner,/mnt/shared/inbox" ), ) watch_folder_poll_interval: int = Field( default=1, description=("Poll interval in minutes for local watch folder scanning. Default: 1 minute."), ) watch_folder_delete_after_process: bool = Field( default=False, description=( "Delete files from local watch folders after they have been successfully enqueued " "for processing. When False (default), files are left in place and tracked via a " "cache file to avoid re-ingesting them." ), ) # FTP Ingest / Watch Folder # Uses the existing FTP credentials (ftp_host, ftp_username, ftp_password) to poll # a source folder on the FTP server for new files to ingest. ftp_ingest_folder: Optional[str] = Field( default=None, description=( "FTP folder path to monitor for new files to ingest. " "Uses the existing FTP connection settings (FTP_HOST, FTP_USERNAME, FTP_PASSWORD). " "When set, DocuElevate will periodically poll this folder and download new files for processing." ), ) ftp_ingest_enabled: bool = Field( default=False, description="Enable FTP watch folder ingestion. Requires FTP_INGEST_FOLDER and FTP connection settings.", ) ftp_ingest_delete_after_process: bool = Field( default=False, description=( "Delete files from the FTP ingest folder after they have been successfully downloaded " "and enqueued for processing. Default: False (files are left in place)." ), ) # SFTP Ingest / Watch Folder # Uses the existing SFTP credentials (sftp_host, sftp_username, sftp_password/sftp_private_key) # to poll a source folder on the SFTP server for new files to ingest. sftp_ingest_folder: Optional[str] = Field( default=None, description=( "SFTP folder path to monitor for new files to ingest. " "Uses the existing SFTP connection settings (SFTP_HOST, SFTP_USERNAME, SFTP_PASSWORD/SFTP_PRIVATE_KEY). " "When set, DocuElevate will periodically poll this folder and download new files for processing." ), ) sftp_ingest_enabled: bool = Field( default=False, description="Enable SFTP watch folder ingestion. Requires SFTP_INGEST_FOLDER and SFTP connection settings.", ) sftp_ingest_delete_after_process: bool = Field( default=False, description=( "Delete files from the SFTP ingest folder after they have been successfully downloaded " "and enqueued for processing. Default: False (files are left in place)." ), ) # --------------------------------------------------------------------------- # Cloud Provider Watch Folders # --------------------------------------------------------------------------- # Each cloud provider has three settings: # _ingest_enabled — enable the watch-folder for this provider # _ingest_folder — the remote path / folder ID to poll # _ingest_delete_after_process — delete from cloud after download # Dropbox ingest — reuses existing Dropbox OAuth credentials dropbox_ingest_enabled: bool = Field( default=False, description="Enable Dropbox watch folder ingestion. Requires Dropbox OAuth credentials.", ) dropbox_ingest_folder: Optional[str] = Field( default=None, description=( "Dropbox folder path to poll for new files to ingest (e.g. /Inbox/Scanner). " "Uses the existing Dropbox OAuth credentials." ), ) dropbox_ingest_delete_after_process: bool = Field( default=False, description="Delete files from Dropbox ingest folder after download and enqueue.", ) # Google Drive ingest — reuses existing Google Drive credentials google_drive_ingest_enabled: bool = Field( default=False, description="Enable Google Drive watch folder ingestion. Requires Google Drive credentials.", ) google_drive_ingest_folder_id: Optional[str] = Field( default=None, description=( "Google Drive folder ID to poll for new files to ingest. " "Uses the existing Google Drive service-account or OAuth credentials." ), ) google_drive_ingest_delete_after_process: bool = Field( default=False, description="Delete files from Google Drive ingest folder after download and enqueue.", ) # OneDrive ingest — reuses existing OneDrive MSAL credentials onedrive_ingest_enabled: bool = Field( default=False, description="Enable OneDrive watch folder ingestion. Requires OneDrive MSAL credentials.", ) onedrive_ingest_folder_path: Optional[str] = Field( default=None, description=( "OneDrive folder path to poll for new files to ingest (e.g. /Inbox/Scanner). " "Uses the existing OneDrive client credentials." ), ) onedrive_ingest_delete_after_process: bool = Field( default=False, description="Delete files from OneDrive ingest folder after download and enqueue.", ) # Nextcloud ingest — reuses existing Nextcloud WebDAV credentials nextcloud_ingest_enabled: bool = Field( default=False, description="Enable Nextcloud watch folder ingestion. Requires Nextcloud WebDAV credentials.", ) nextcloud_ingest_folder: Optional[str] = Field( default=None, description=( "Nextcloud folder path to poll for new files to ingest (e.g. /Scans/Inbox). " "Uses the existing Nextcloud upload URL and credentials." ), ) nextcloud_ingest_delete_after_process: bool = Field( default=False, description="Delete files from Nextcloud ingest folder after download and enqueue.", ) # S3 ingest — reuses existing AWS/S3 credentials s3_ingest_enabled: bool = Field( default=False, description="Enable Amazon S3 watch folder (prefix) ingestion. Requires S3 credentials.", ) s3_ingest_prefix: Optional[str] = Field( default=None, description=( "S3 key prefix to poll for new objects to ingest (e.g. inbox/scanner/). " "Uses the existing S3 bucket and AWS credentials." ), ) s3_ingest_delete_after_process: bool = Field( default=False, description="Delete objects from S3 ingest prefix after download and enqueue.", ) # WebDAV ingest — reuses existing WebDAV credentials webdav_ingest_enabled: bool = Field( default=False, description="Enable WebDAV watch folder ingestion. Requires WebDAV URL and credentials.", ) webdav_ingest_folder: Optional[str] = Field( default=None, description=( "WebDAV folder path to poll for new files to ingest (e.g. /remote.php/webdav/Inbox). " "Uses the existing WebDAV URL and credentials." ), ) webdav_ingest_delete_after_process: bool = Field( default=False, description="Delete files from WebDAV ingest folder after download and enqueue.", ) # IMAP 1 imap1_host: Optional[str] = None imap1_port: Optional[int] = 993 imap1_username: Optional[str] = None imap1_password: Optional[str] = None imap1_ssl: bool = True imap1_poll_interval_minutes: int = 5 imap1_delete_after_process: bool = False # IMAP 2 imap2_host: Optional[str] = None imap2_port: Optional[int] = 993 imap2_username: Optional[str] = None imap2_password: Optional[str] = None imap2_ssl: bool = True imap2_poll_interval_minutes: int = 10 imap2_delete_after_process: bool = False # Google Drive settings google_drive_enabled: bool = Field( default=True, description="Enable Google Drive as an upload destination. Set to False to disable uploads even when credentials are configured.", ) google_drive_credentials_json: Optional[str] = "" google_drive_folder_id: Optional[str] = "" google_drive_delegate_to: Optional[str] = "" # Optional delegated user email # Google Drive OAuth settings google_drive_use_oauth: bool = False # Default to service account method google_drive_client_id: Optional[str] = "" google_drive_client_secret: Optional[str] = "" google_drive_refresh_token: Optional[str] = "" # WebDAV settings webdav_enabled: bool = Field( default=True, description="Enable WebDAV as an upload destination. Set to False to disable uploads even when credentials are configured.", ) webdav_url: Optional[str] = None webdav_username: Optional[str] = None webdav_password: Optional[str] = None webdav_folder: Optional[str] = None webdav_verify_ssl: bool = True # FTP settings ftp_enabled: bool = Field( default=True, description="Enable FTP as an upload destination. Set to False to disable uploads even when credentials are configured.", ) ftp_host: Optional[str] = None ftp_port: Optional[int] = 21 ftp_username: Optional[str] = None ftp_password: Optional[str] = None ftp_folder: Optional[str] = None ftp_use_tls: bool = True # Default to attempting TLS connection first ftp_allow_plaintext: bool = True # Default to allowing plaintext fallback # SFTP settings sftp_enabled: bool = Field( default=True, description="Enable SFTP as an upload destination. Set to False to disable uploads even when credentials are configured.", ) sftp_host: Optional[str] = None sftp_port: Optional[int] = 22 sftp_username: Optional[str] = None sftp_password: Optional[str] = None sftp_folder: Optional[str] = None sftp_private_key: Optional[str] = None sftp_private_key_passphrase: Optional[str] = None # Security: Host key verification is enabled by default for security # In development/testing, set to True to disable verification (not recommended) sftp_disable_host_key_verification: bool = False # Default enforces host key verification # Email settings (shared SMTP – used for password reset, verification emails, etc.) email_host: Optional[str] = None email_port: Optional[int] = 587 email_username: Optional[str] = None email_password: Optional[str] = None email_use_tls: bool = True email_sender: Optional[str] = None # From address, defaults to email_username if not set email_default_recipient: Optional[str] = None # Email destination settings (dedicated SMTP for document delivery – decoupled from shared email above) dest_email_enabled: bool = Field( default=True, description="Enable Email as an upload destination. Set to False to disable document delivery via email even when credentials are configured.", ) dest_email_host: Optional[str] = None dest_email_port: Optional[int] = 587 dest_email_username: Optional[str] = None dest_email_password: Optional[str] = None dest_email_use_tls: bool = True dest_email_sender: Optional[str] = None # From address for delivered documents dest_email_default_recipient: Optional[str] = None # Fallback recipient for document delivery # OneDrive settings onedrive_enabled: bool = Field( default=True, description="Enable OneDrive as an upload destination. Set to False to disable uploads even when credentials are configured.", ) onedrive_client_id: Optional[str] = None onedrive_client_secret: Optional[str] = None onedrive_tenant_id: Optional[str] = "common" # Default to "common" for personal accounts onedrive_refresh_token: Optional[str] = None # Required for personal accounts onedrive_folder_path: Optional[str] = None # SharePoint settings sharepoint_client_id: Optional[str] = None sharepoint_client_secret: Optional[str] = None sharepoint_tenant_id: Optional[str] = "common" sharepoint_refresh_token: Optional[str] = None sharepoint_site_url: Optional[str] = None # e.g. https://tenant.sharepoint.com/sites/sitename sharepoint_document_library: Optional[str] = "Documents" # Document library name sharepoint_folder_path: Optional[str] = None # Subfolder inside the library # AWS S3 settings s3_enabled: bool = Field( default=True, description="Enable Amazon S3 as an upload destination. Set to False to disable uploads even when credentials are configured.", ) aws_access_key_id: Optional[str] = None aws_secret_access_key: Optional[str] = None aws_region: Optional[str] = "us-east-1" # Default region s3_bucket_name: Optional[str] = None s3_folder_prefix: Optional[str] = "" # Optional folder prefix (e.g. "uploads/") s3_storage_class: Optional[str] = "STANDARD" # Default storage class s3_acl: Optional[str] = "private" # Default ACL # iCloud Drive settings icloud_enabled: bool = Field( default=True, description="Enable iCloud Drive as an upload destination. Set to False to disable uploads even when credentials are configured.", ) icloud_username: Optional[str] = None # Apple ID email address icloud_password: Optional[str] = None # App-specific password (required for 2FA accounts) icloud_folder: Optional[str] = None # Target folder path in iCloud Drive (e.g. "Documents/Uploads") icloud_cookie_directory: Optional[str] = None # Directory for session cookies (default: ~/.pyicloud) # Uptime Kuma settings uptime_kuma_url: Optional[str] = None uptime_kuma_ping_interval: int = 5 # Default ping interval in minutes # Meilisearch settings (full-text search engine) # Default uses the Docker Compose / K8s service name so container-to-container # networking works without any extra configuration. Override to # "http://localhost:7700" only when running the API process outside of Docker. meilisearch_url: str = "http://meilisearch:7700" meilisearch_api_key: Optional[str] = None # Master or API key (optional for local dev) meilisearch_index_name: str = "documents" enable_search: bool = True # Enable Meilisearch full-text search integration # HTTP request settings http_request_timeout: int = 120 # Default timeout for HTTP requests in seconds (handles large file operations) # Feature flags allow_file_delete: bool = True # Default to allowing file deletion from database compliance_enabled: bool = Field( default=True, description=( "Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). " "When enabled, admins can view compliance status and apply " "pre-built regulatory configurations. Default: True." ), ) # System reset / factory reset settings factory_reset_on_startup: bool = Field( default=False, description=( "When enabled, DocuElevate wipes all user data (database rows and " "work-files on disk) on every startup so the instance always comes " "up in a clean, fresh state. Useful for demo or testing environments. " "Default: False." ), ) enable_factory_reset: bool = Field( default=False, description=( "Show the 'System Reset' page in the admin UI. When enabled, " "administrators can trigger a full data wipe or a wipe-and-reimport " "directly from the web interface. Default: False." ), ) # PDF/A archival conversion settings enable_pdfa_conversion: bool = Field( default=False, description=( "Enable PDF/A archival variant generation. When enabled, PDF/A copies of both the " "original ingested file and the processed file are created and saved alongside the " "standard copies. Uses ocrmypdf with Ghostscript for the conversion. " "This may double or triple storage but provides better legal coverage. Default: False." ), ) pdfa_format: str = Field( default="2", description=( "PDF/A format variant to produce. Passed to ocrmypdf --output-type pdfa-N. " "Valid values: '1' (PDF/A-1b), '2' (PDF/A-2b), '3' (PDF/A-3b). Default: '2'." ), ) pdfa_upload_original: bool = Field( default=False, description=( "Upload the original-file PDF/A variant to all configured storage providers. " "Files are placed in the provider's folder + PDFA_UPLOAD_FOLDER subfolder. Default: False." ), ) pdfa_upload_processed: bool = Field( default=False, description=( "Upload the processed-file PDF/A variant to all configured storage providers. " "Files are placed in the provider's folder + PDFA_UPLOAD_FOLDER subfolder. Default: False." ), ) pdfa_upload_folder: str = Field( default="pdfa", description=( "Subfolder name appended to each storage provider's configured folder for PDF/A uploads. " "For example if Dropbox folder is '/Documents' and this is 'pdfa', PDF/A files go to " "'/Documents/pdfa'. Set to empty string to upload into the same folder. Default: 'pdfa'." ), ) google_drive_pdfa_folder_id: str = Field( default="", description=( "Google Drive folder ID for PDF/A uploads. Since Google Drive uses IDs not paths, " "this must be set separately. If empty, uses the standard google_drive_folder_id." ), ) pdfa_timestamp_enabled: bool = Field( default=False, description=( "Enable RFC 3161 timestamping of PDF/A files via a Timestamp Authority (TSA). " "Creates a .tsr file alongside each PDF/A file for legal proof of existence. " "Requires openssl binary on PATH. Default: False." ), ) pdfa_timestamp_url: str = Field( default="https://freetsa.org/tsr", description=( "URL of the RFC 3161 Timestamp Authority. Default: FreeTSA (https://freetsa.org/tsr). " "Other options: GlobalSign, DigiStamp, or any RFC 3161-compliant TSA." ), ) imap_readonly_mode: bool = Field( default=False, description=( "When enabled, IMAP processing will fetch and process attachments but will NOT modify " "the mailbox state (no starring, labeling, deleting, or flag changes). " "Use this for pre-production instances that share a mailbox with production to prevent " "preprod from interfering with production email processing." ), ) imap_attachment_filter: str = Field( default="documents_only", description=( "Controls which attachment types are ingested from IMAP emails. " "Accepted values: " "'documents_only' – ingest only PDFs and office files (Word, Excel, PowerPoint, ODT, etc.); " "'all' – ingest all supported file types including images. " "This is the global default; individual user IMAP accounts can override it." ), ) # Batch processing settings processall_throttle_threshold: int = Field( default=20, description="Number of files above which throttling is applied in /processall endpoint", ) processall_throttle_delay: int = Field( default=3, description="Delay in seconds between each task submission when throttling in /processall", ) # Client-side upload throttling settings (applied when uploading files via the web UI) upload_concurrency: int = Field( default=3, description=( "Maximum number of files uploaded simultaneously from the browser. " "Limits parallel uploads to prevent API overload when dragging directories. Default: 3." ), ) upload_queue_delay_ms: int = Field( default=500, description=( "Delay in milliseconds between starting each upload slot when queue is active. " "Staggers upload starts to smooth out server load. Default: 500 ms." ), ) # Telegram Bot telegram_bot_token: Optional[str] = None telegram_chat_id: Optional[str] = None telegram_enabled: bool = False # Notification settings notification_urls: Union[List[str], str] = Field( default_factory=list, description="List of Apprise notification URLs (e.g., discord://, telegram://, etc.)", ) notify_on_task_failure: bool = Field(default=True, description="Send notifications when Celery tasks fail") notify_on_credential_failure: bool = Field( default=True, description="Send notifications when credential checks fail" ) notify_on_startup: bool = Field(default=True, description="Send notifications when application starts") notify_on_shutdown: bool = Field(default=False, description="Send notifications when application shuts down") notify_on_file_processed: bool = Field( default=True, description="Send notifications when files are successfully processed", ) notify_on_user_signup: bool = Field( default=True, description="Send admin notifications when a new user signs up", ) notify_on_plan_change: bool = Field( default=True, description="Send admin notifications when a user changes their subscription plan", ) notify_on_payment_issue: bool = Field( default=True, description="Send admin notifications when a payment issue is reported for a user", ) # Webhook settings webhook_enabled: bool = Field( default=True, description="Enable webhook delivery for document events", ) # Automation hooks (Zapier / Make.com) automation_hooks_enabled: bool = Field( default=True, description="Enable Zapier / Make.com automation hook subscriptions and delivery", ) # ── Backup / restore settings ────────────────────────────────────────────── backup_enabled: bool = Field( default=True, description=( "Enable automatic scheduled database backups. " "When enabled, hourly, daily, and weekly backups are created automatically. Default: True." ), ) backup_dir: Optional[str] = Field( default=None, description=("Directory where local backup archives are stored. Defaults to /backups when not set."), ) # Remote destination: one of s3, dropbox, google_drive, onedrive, nextcloud, # webdav, ftp, sftp, email, or empty/None for local-only. backup_remote_destination: Optional[str] = Field( default=None, description=( "Storage provider to upload remote backup copies to. " "Accepted values: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email. " "Leave empty to keep backups local only." ), ) backup_remote_folder: str = Field( default="backups", description=( "Sub-folder / key prefix used when uploading backup archives to the remote destination. Default: 'backups'." ), ) # Retention counts (number of snapshots to keep per tier) backup_retain_hourly: int = Field( default=96, description="Number of hourly backups to retain (default 96 = 4 days × 24 h).", ) backup_retain_daily: int = Field( default=21, description="Number of daily backups to retain (default 21 = 3 weeks).", ) backup_retain_weekly: int = Field( default=13, description="Number of weekly backups to retain (default 13 ≈ 3 months / 91 days).", ) # File upload size limits (for security - see SECURITY_AUDIT.md) max_upload_size: int = Field( default=1073741824, # 1GB in bytes (1024 * 1024 * 1024) description="Maximum file upload size in bytes. Default: 1GB. Prevents resource exhaustion attacks.", ) max_single_file_size: Optional[int] = Field( default=None, description=( "Maximum size for a single file chunk in bytes. If set and file exceeds this," " it will be split into smaller chunks for processing. Default: None (no splitting)." ), ) max_request_body_size: int = Field( default=1048576, # 1MB in bytes (1024 * 1024) description=( "Maximum request body size in bytes for non-file-upload requests. Default: 1MB." " Prevents memory exhaustion attacks via oversized JSON/form payloads." " File uploads are governed by MAX_UPLOAD_SIZE instead." ), ) # Deduplication settings - prevents processing of duplicate files enable_deduplication: bool = Field( default=True, description=( "Enable deduplication check before processing. If enabled, files with the same SHA-256 hash" " as previously processed files will not be processed again. Default: True (enabled)." ), ) show_deduplication_step: bool = Field( default=True, description=( "Show the 'Check for Duplicates' step in processing history." " If False, the check is still performed but not displayed. Default: True." ), ) near_duplicate_threshold: float = Field( default=0.85, description=( "Minimum cosine similarity score (0–1) between two documents' text embeddings to consider " "them near-duplicates. Higher values require closer content matches. Default: 0.85." ), ) embedding_model: str = Field( default="text-embedding-3-small", description=( "Model name used for generating text embeddings via the OpenAI-compatible API. " "Embeddings drive the document similarity feature. Default: text-embedding-3-small." ), ) embedding_max_tokens: int = Field( default=8000, description=( "Maximum number of tokens to send to the embedding model. " "Text is truncated to approximately this many tokens (using a " "conservative 3-chars-per-token estimate) before calling the API. " "Set this below the model's context window (e.g. 8000 for an 8192-token model)." ), ) embedding_backfill_batch_size: int = Field( default=50, description=( "Maximum number of files to queue for embedding computation per " "backfill run. Keeps the worker and embedding API load bounded." ), ) # Text quality check - AI-based assessment of embedded PDF text enable_text_quality_check: bool = Field( default=True, description=( "Enable AI-based quality check for embedded PDF text. " "When enabled, text extracted from non-digital PDFs is evaluated by the AI model. " "If the text is poor quality (OCR artefacts, typos, incoherence), the file is " "re-processed with OCR instead of using the embedded text. " "Digitally-created PDFs (Word, LibreOffice, LaTeX, etc.) are always trusted and " "bypass the check. Default: True (enabled)." ), ) text_quality_threshold: int = Field( default=85, description=( "Minimum quality score (0–100) required to accept embedded PDF text without re-OCR. " "Text scoring below this threshold is discarded and the file is re-processed with OCR. " "Default: 85. The stricter this value, the more files will be re-OCR'd." ), ) text_quality_significant_issues: Union[List[str], str] = Field( default_factory=lambda: ["excessive_typos", "garbage_characters", "incoherent_text", "fragmented_sentences"], description=( "Comma-separated list of quality issue labels that force OCR re-run even when the quality " "score is above TEXT_QUALITY_THRESHOLD. Any of these issues present in the AI assessment " "will trigger re-OCR. Default: excessive_typos,garbage_characters,incoherent_text,fragmented_sentences" ), ) # --------------------------------------------------------------------------- # Task retry settings (see app/tasks/retry_config.py) # --------------------------------------------------------------------------- task_retry_max_retries: int = Field( default=3, description=("Maximum number of automatic retry attempts for failed Celery tasks. Default: 3."), ) task_retry_delays: Union[List[int], str] = Field( default_factory=lambda: [60, 300, 900], description=( "Comma-separated list of retry countdown values in seconds. " "Each value is the delay before the corresponding retry attempt. " "If a task fails more times than entries in this list, the last delay " "is doubled for each additional attempt. " "Default: 60,300,900 (1 min, 5 min, 15 min)." ), ) task_retry_jitter: bool = Field( default=True, description=( "Apply ±20 % random jitter to retry countdowns to prevent " "thundering-herd problems when many tasks fail simultaneously. " "Default: True (enabled)." ), ) # Processing step timeout - prevents files from getting stuck in "in_progress" state step_timeout: int = Field( default=600, description=( "Timeout in seconds for processing steps. If a step is 'in_progress' for longer than this," " it will be marked as failed. Default: 600 seconds (10 minutes)." ), ) # Security Headers Configuration (see SECURITY_AUDIT.md and docs/DeploymentGuide.md) # Disabled by default since most deployments use a reverse proxy (Traefik, Nginx, etc.) # that already adds these headers. Enable if deploying directly without a reverse proxy. security_headers_enabled: bool = Field( default=False, description="Enable security headers middleware. Set to True if deploying without reverse proxy.", ) # Strict-Transport-Security (HSTS) - Forces HTTPS connections security_header_hsts_enabled: bool = Field( default=True, description="Enable HSTS header. Only effective over HTTPS." ) security_header_hsts_value: str = Field( default="max-age=31536000; includeSubDomains", description="HSTS header value. Default: 1 year with subdomains.", ) # Content-Security-Policy (CSP) - Controls resource loading security_header_csp_enabled: bool = Field(default=True, description="Enable CSP header.") security_header_csp_value: str = Field( default=( "default-src 'self'; script-src 'self' 'unsafe-inline';" " style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;" ), description="CSP header value. Customize based on your application's resource loading needs.", ) # X-Frame-Options - Prevents clickjacking security_header_x_frame_options_enabled: bool = Field(default=True, description="Enable X-Frame-Options header.") security_header_x_frame_options_value: str = Field( default="DENY", description="X-Frame-Options header value. Options: DENY, SAMEORIGIN, or ALLOW-FROM uri", ) # X-Content-Type-Options - Prevents MIME sniffing security_header_x_content_type_options_enabled: bool = Field( default=True, description="Enable X-Content-Type-Options header (always set to 'nosniff').", ) # Audit Logging Configuration (see SECURITY_AUDIT.md – Infrastructure Security) # Logs every HTTP request and security-relevant events (auth failures, 5xx errors). # Sensitive query-parameter values (passwords, tokens, keys) are always masked. audit_logging_enabled: bool = Field( default=True, description=( "Enable audit/request logging middleware. Logs every HTTP request with " "method, path, status code, response time, and username. " "Sensitive query-parameter values are automatically masked." ), ) audit_log_include_client_ip: bool = Field( default=True, description=( "Include the client IP address in audit log entries. " "Disable for privacy-sensitive deployments where IP logging is restricted." ), ) # SIEM / External Audit Log Forwarding # Forward audit events to external SIEM systems for centralised monitoring. audit_siem_enabled: bool = Field( default=False, description="Enable forwarding of audit events to an external SIEM system.", ) audit_siem_transport: str = Field( default="syslog", description=( "Transport used to forward audit events. " "Options: 'syslog' (RFC 5424 over UDP/TCP), 'http' (JSON POST to a webhook URL, " "compatible with Splunk HEC, Logstash HTTP input, Grafana Loki, etc.)." ), ) audit_siem_syslog_host: str = Field( default="localhost", description="Hostname or IP of the syslog receiver.", ) audit_siem_syslog_port: int = Field( default=514, description="Port of the syslog receiver.", ) audit_siem_syslog_protocol: str = Field( default="udp", description="Protocol for syslog transport: 'udp' or 'tcp'.", ) audit_siem_http_url: str = Field( default="", description=( "HTTP endpoint URL for SIEM webhook delivery. " "Supports Splunk HEC (https://splunk:8088/services/collector/event), " "Logstash HTTP input, Grafana Loki push API, or any JSON-accepting endpoint." ), ) audit_siem_http_token: str = Field( default="", description="Bearer / HEC token included in the Authorization header of SIEM HTTP requests.", ) audit_siem_http_custom_headers: str = Field( default="", description="Comma-separated 'Key:Value' pairs of extra headers for SIEM HTTP requests.", ) # UI / Appearance ui_default_color_scheme: str = Field( default="system", description=( "Default color scheme for the web interface. " "Options: 'system' (follow OS preference), 'light', 'dark'. " "Individual users can override this with the in-app toggle; their choice is persisted in localStorage." ), ) # Per-user upload rate limiting (health-aware, Redis-backed sliding window) upload_rate_limit_per_user: int = Field( default=20, description=( "Maximum number of file uploads allowed per user within the sliding window. " "The effective limit may be reduced dynamically when the system is under heavy load " "(high queue depth or CPU usage). Set to 0 to disable per-user upload rate limiting." ), ) upload_rate_limit_window: int = Field( default=60, description="Sliding window size in seconds for per-user upload rate limiting (default: 60).", ) # Rate Limiting Configuration (see SECURITY_AUDIT.md and docs/API.md) # Protects against DoS attacks and API abuse rate_limiting_enabled: bool = Field( default=True, description="Enable rate limiting middleware. Recommended for production to prevent abuse.", ) rate_limit_default: str = Field( default="100/minute", description="Default rate limit for all endpoints (format: 'count/period', e.g., '100/minute', '1000/hour').", ) rate_limit_upload: str = Field( default="600/minute", description="Rate limit for file upload endpoints to prevent resource exhaustion.", ) rate_limit_auth: str = Field( default="10/minute", description="Stricter rate limit for authentication endpoints to prevent brute force attacks.", ) # CORS Configuration (see SECURITY_AUDIT.md – Infrastructure Security section) # Disabled by default since most deployments use a reverse proxy (Traefik, Nginx, etc.) # that already adds CORS headers. Enable only if deploying without a reverse proxy or if # the proxy does not handle CORS. See docs/DeploymentGuide.md for rationale. cors_enabled: bool = Field( default=False, description=( "Enable CORS middleware. Set to False if reverse proxy (Traefik, Nginx) handles CORS headers. " "When True, CORSMiddleware is added to the application with the settings below." ), ) cors_allowed_origins: Union[List[str], str] = Field( default_factory=lambda: ["*"], description=( "List of allowed CORS origins. Use ['*'] to allow all origins (not recommended with " "cors_allow_credentials=True). Comma-separated string is also accepted via env var, " "e.g. CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com" ), ) cors_allow_credentials: bool = Field( default=False, description=( "Allow credentials (cookies, Authorization headers) in CORS requests. " "Cannot be True when cors_allowed_origins=['*']. " "When True, set cors_allowed_origins to specific origins." ), ) cors_allowed_methods: Union[List[str], str] = Field( default_factory=lambda: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], description="Allowed HTTP methods for CORS requests.", ) cors_allowed_headers: Union[List[str], str] = Field( default_factory=lambda: ["*"], description="Allowed request headers for CORS. Use ['*'] to allow all headers.", ) # --------------------------------------------------------------------------- # Support / Help Center – Zammad integration # --------------------------------------------------------------------------- zammad_url: Optional[str] = Field( default=None, description=( "Base URL of your Zammad instance (e.g. https://zammad.example.com). " "Required for the chat widget and feedback form on the Help Center page." ), ) zammad_chat_enabled: bool = Field( default=False, description="Show the Zammad live-chat widget on the Help Center page.", ) zammad_chat_id: int = Field( default=1, description="Zammad chat topic ID to use for the live-chat widget.", ) zammad_form_enabled: bool = Field( default=False, description="Show the Zammad feedback / ticket form on the Help Center page.", ) support_email: Optional[str] = Field( default=None, description="Support e-mail address displayed on the Help Center page.", ) # --------------------------------------------------------------------------- # Observability – Sentry error & performance monitoring # --------------------------------------------------------------------------- sentry_dsn: Optional[str] = Field( default=None, description=( "Sentry Data Source Name (DSN). When set, error reporting and " "performance tracing are enabled automatically. Leave blank (or unset) " "to disable Sentry entirely." ), ) sentry_environment: str = Field( default="production", description=( "Environment tag sent to Sentry (e.g. 'development', 'staging', 'production'). " "Helps you filter events in the Sentry dashboard." ), ) sentry_traces_sample_rate: float = Field( default=0.1, description=( "Fraction of transactions to capture for performance monitoring (0.0–1.0). " "Set to 0.0 to disable tracing, 1.0 to capture every transaction. " "Values above 0 may increase Sentry quota usage." ), ) sentry_profiles_sample_rate: float = Field( default=0.0, description=( "Fraction of profiled transactions to send to Sentry (0.0–1.0). " "Profiling is only active when traces_sample_rate > 0. " "Defaults to 0.0 (disabled) to minimise overhead." ), ) sentry_send_default_pii: bool = Field( default=False, description=( "Whether to attach personally identifiable information (PII) – such as " "IP addresses and user agents – to Sentry events. Disabled by default " "for privacy compliance (GDPR / CCPA). Enable only if your Sentry " "project is configured to handle PII." ), ) # --------------------------------------------------------------------------- # Observability – Sentry Browser JavaScript SDK (client-side) # --------------------------------------------------------------------------- # The same SENTRY_DSN is reused for the browser SDK. The DSN is a *public* # key in Sentry's model and is intentionally embedded in client-side code. # All three settings below default to 0.0 / disabled so that operators opt-in # to the level of browser monitoring they want. # --------------------------------------------------------------------------- sentry_js_traces_sample_rate: float = Field( default=0.0, description=( "Fraction of browser page-loads captured for client-side performance tracing " "(0.0 – 1.0). 0.0 disables browser tracing; 1.0 captures every navigation. " "Only active when SENTRY_DSN is set." ), ) sentry_js_replay_session_sample_rate: float = Field( default=0.0, description=( "Fraction of sessions recorded by Sentry Session Replay (0.0 – 1.0). " "0.0 disables session recording; 1.0 records every session. " "Only active when SENTRY_DSN is set." ), ) sentry_js_replay_on_error_sample_rate: float = Field( default=0.1, description=( "Fraction of sessions with an error that will be recorded by Sentry Session " "Replay (0.0 – 1.0). Defaults to 0.1 (10 %) so that errors are captured " "with replay context even when session-level recording is disabled. " "Only active when SENTRY_DSN is set." ), ) @model_validator(mode="before") @classmethod def strip_outer_quotes(cls, data: Any) -> Any: """ Strip matching surrounding quotes from string values. In Kubernetes (and some other environments) env var values can arrive with literal quote characters included, e.g. the value for DATABASE_URL may be ``"postgresql://..."`` (with the quotes as part of the string) rather than just ``postgresql://...``. Docker Compose strips these automatically; Kubernetes does not. """ if isinstance(data, dict): for key, value in data.items(): if isinstance(value, str) and len(value) >= 2: if (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'"): data[key] = value[1:-1] return data @field_validator("notification_urls", mode="before") @classmethod def parse_notification_urls(cls, v: str | list[str]) -> list[str]: """Parse notification URLs from string or list""" if isinstance(v, str): if "," in v: return [url.strip() for url in v.split(",") if url.strip()] elif v.strip(): return [v.strip()] return [] return v @field_validator("text_quality_significant_issues", mode="before") @classmethod def parse_text_quality_significant_issues(cls, v: str | list[str]) -> list[str]: """Parse significant issue labels from comma-separated string or list.""" if isinstance(v, str): if "," in v: return [item.strip() for item in v.split(",") if item.strip()] elif v.strip(): return [v.strip()] return [] return v @field_validator("cors_allowed_origins", "cors_allowed_methods", "cors_allowed_headers", mode="before") @classmethod def parse_comma_separated_list(cls, v: str | list[str]) -> list[str]: """Parse comma-separated string or list for CORS list settings.""" if isinstance(v, str): if "," in v: return [item.strip() for item in v.split(",") if item.strip()] elif v.strip(): return [v.strip()] return [] return v @field_validator("task_retry_delays", mode="before") @classmethod def parse_task_retry_delays(cls, v: str | list) -> list[int]: """Parse task retry delays from comma-separated string or list of ints.""" if isinstance(v, str): parts = [p.strip() for p in v.split(",") if p.strip()] return [int(p) for p in parts] return [int(item) for item in v] @field_validator("session_secret") @classmethod def validate_session_secret(cls, v: str | None, info: object) -> str | None: """Validate that session_secret is set and has sufficient length when auth is enabled""" if info.data.get("auth_enabled") and not v: raise ValueError("SESSION_SECRET must be set when AUTH_ENABLED=True") if info.data.get("auth_enabled") and v and len(v) < 32: raise ValueError("SESSION_SECRET must be at least 32 characters long") return v # Get build date from environment or file @property def build_date(self) -> str: # First try to get build date from environment env_build_date = os.environ.get("BUILD_DATE") if env_build_date: return env_build_date # Then try to get build date from BUILD_DATE file build_date_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "BUILD_DATE") if os.path.exists(build_date_file): with open(build_date_file, "r") as f: return f.read().strip() # Default to unknown if not found return "Unknown build date" # Get version from file or environment @property def version(self) -> str: # First try to get version from environment env_version = os.environ.get("APP_VERSION") if env_version: return env_version # Then try to get version from VERSION file version_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "VERSION") if os.path.exists(version_file): with open(version_file, "r") as f: return f.read().strip() # Default version if not found return "unknown" @property def git_sha(self) -> str: """Get Git commit SHA from environment or file.""" # First try to get from environment env_sha = os.environ.get("GIT_COMMIT_SHA") if env_sha: return env_sha # Then try to get from GIT_SHA file git_sha_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "GIT_SHA") if os.path.exists(git_sha_file): with open(git_sha_file, "r") as f: return f.read().strip() # Default if not found return "unknown" @property def runtime_info(self) -> str: """Get runtime information from file.""" runtime_info_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "RUNTIME_INFO") if os.path.exists(runtime_info_file): with open(runtime_info_file, "r") as f: return f.read().strip() # Return basic info if file not found return f"Version: {self.version}\nBuild Date: {self.build_date}\nGit SHA: {self.git_sha}" @property def release_name(self) -> str | None: """Get the release codename for the current version from release_names.json. Looks up the current version's minor version prefix (e.g., '0.5' for '0.5.3') in release_names.json to find the associated codename. Returns None if no codename is defined for the current version. Returns: The release codename string, or None if not found. """ version = self.version if not version or version == "unknown": return None release_names_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "release_names.json") if not os.path.exists(release_names_file): return None try: with open(release_names_file, "r") as f: data = json.load(f) releases = data.get("releases", {}) # Try exact version match first (e.g., "0.5.0") if version in releases: return releases[version].get("codename") # Try minor version prefix (e.g., "0.5" for "0.5.3") parts = version.split(".") if len(parts) >= 2: minor_prefix = f"{parts[0]}.{parts[1]}" if minor_prefix in releases: return releases[minor_prefix].get("codename") # Try major version prefix (e.g., "1" for "1.0.0") if len(parts) >= 1: major_prefix = parts[0] if major_prefix in releases: return releases[major_prefix].get("codename") return None except (json.JSONDecodeError, KeyError, IndexError): return None settings = Settings()