""" Unified Configuration for ROA2WEB Backend Consolidates settings from Reports, Data Entry, and Telegram modules """ import os import json import logging from pathlib import Path from typing import List, Optional from pydantic_settings import BaseSettings from pydantic import BaseModel from functools import lru_cache logger = logging.getLogger(__name__) class OracleServerConfig(BaseModel): """Configuration for a single Oracle server instance.""" id: str # Unique identifier (e.g., "romfast", "client_a") name: str # Human-readable name (e.g., "Romfast - Producție") host: str = "localhost" port: int = 1521 user: str password: str sid: Optional[str] = None service_name: Optional[str] = None def get_dsn(self) -> str: """Build DSN string for this server.""" if self.service_name: return f"{self.host}:{self.port}/{self.service_name}" elif self.sid: return f"{self.host}:{self.port}:{self.sid}" else: return f"{self.host}:{self.port}/ROA" class UnifiedSettings(BaseSettings): """Unified application settings for all modules.""" # ============================================================================ # GENERAL APPLICATION SETTINGS # ============================================================================ app_name: str = "ROA2WEB Unified Backend" app_version: str = "1.0.0" debug: bool = False api_host: str = "0.0.0.0" api_port: int = 8000 # ============================================================================ # ORACLE DATABASE (Shared by all modules) # ============================================================================ # Legacy single-server configuration (backward compatible) oracle_user: str = "" oracle_password: str = "" oracle_host: str = "localhost" oracle_port: int = 1526 oracle_sid: str = "ROA" # ============================================================================ # MULTI-ORACLE SERVER CONFIGURATION (Optional) # ============================================================================ # JSON array of server configs. If not set, uses legacy single-server config. # Example: ORACLE_SERVERS='[{"id": "romfast", "name": "Romfast", "host": "localhost", "port": 1521, "user": "USER", "password": "PASS", "sid": "ROA"}]' oracle_servers: Optional[str] = None # Raw JSON string from env # Parsed server configurations (populated in model_post_init) _oracle_servers_parsed: List[OracleServerConfig] = [] def model_post_init(self, __context) -> None: """Parse ORACLE_SERVERS JSON and build server list. Oracle passwords are loaded from: 1. secrets/{server_id}.oracle_pass file (preferred, more secure) 2. password field in ORACLE_SERVERS JSON (fallback) """ servers = [] secrets_dir = Path(__file__).parent / "secrets" if self.oracle_servers: # Parse multi-server JSON configuration try: servers_data = json.loads(self.oracle_servers) if not isinstance(servers_data, list): raise ValueError("ORACLE_SERVERS must be a JSON array") for server_data in servers_data: server_id = server_data.get("id", "default") # Try to load password from secrets file pass_file = secrets_dir / f"{server_id}.oracle_pass" if pass_file.exists(): server_data["password"] = pass_file.read_text().strip() logger.debug(f"Loaded Oracle password for '{server_id}' from {pass_file}") elif "password" not in server_data: logger.warning(f"No password found for server '{server_id}' - check secrets/{server_id}.oracle_pass") servers.append(OracleServerConfig(**server_data)) logger.info(f"Loaded {len(servers)} Oracle servers from ORACLE_SERVERS config") for srv in servers: logger.info(f" - {srv.id}: {srv.name} ({srv.host}:{srv.port})") except json.JSONDecodeError as e: logger.error(f"Failed to parse ORACLE_SERVERS JSON: {e}") raise ValueError(f"Invalid ORACLE_SERVERS JSON format: {e}") else: # Backward compatibility: build default server from legacy config if self.oracle_user: # Try to load password from secrets file password = self.oracle_password pass_file = secrets_dir / "default.oracle_pass" if pass_file.exists(): password = pass_file.read_text().strip() logger.debug(f"Loaded Oracle password from {pass_file}") default_server = OracleServerConfig( id="default", name="Default Server", host=self.oracle_host, port=self.oracle_port, user=self.oracle_user, password=password, sid=self.oracle_sid, ) servers.append(default_server) logger.info("Using legacy single-server Oracle configuration (ORACLE_USER/HOST/etc)") logger.info(f" - default: {default_server.host}:{default_server.port}/{default_server.sid}") object.__setattr__(self, '_oracle_servers_parsed', servers) def get_oracle_servers(self) -> List[OracleServerConfig]: """Get list of configured Oracle servers.""" return self._oracle_servers_parsed def get_oracle_server(self, server_id: str) -> Optional[OracleServerConfig]: """Get a specific Oracle server by ID.""" for server in self._oracle_servers_parsed: if server.id == server_id: return server return None def get_default_oracle_server(self) -> Optional[OracleServerConfig]: """Get the default Oracle server (first in list or 'default').""" if not self._oracle_servers_parsed: return None # Try to find server with id='default', otherwise return first for server in self._oracle_servers_parsed: if server.id == "default": return server return self._oracle_servers_parsed[0] # ============================================================================ # JWT AUTHENTICATION (Shared by all modules) # ============================================================================ jwt_secret_key: str = "change-me-in-production" jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 30 refresh_token_expire_days: int = 7 # ============================================================================ # SESSION SECURITY - EMAIL 2FA (Telegram module) # ============================================================================ auth_session_secret: str = "change-me-in-production" # ============================================================================ # CORS # ============================================================================ cors_origins: str = "http://localhost:3000,http://localhost:5173" # ============================================================================ # REPORTS MODULE - CACHE CONFIGURATION # ============================================================================ reports_cache_enabled: bool = True reports_cache_type: str = "hybrid" reports_cache_sqlite_path: str = "./data/cache/roa2web_cache.db" reports_cache_memory_max_size: int = 1000 reports_cache_default_ttl: int = 900 # Cache TTL per type (seconds) reports_cache_ttl_schema: int = 86400 reports_cache_ttl_companies: int = 1800 reports_cache_ttl_dashboard_summary: int = 1800 reports_cache_ttl_dashboard_trends: int = 1800 reports_cache_ttl_invoices: int = 600 reports_cache_ttl_invoices_summary: int = 900 reports_cache_ttl_treasury: int = 600 # Cache maintenance reports_cache_cleanup_interval: int = 3600 reports_cache_auto_invalidate: bool = False reports_cache_check_interval: int = 300 reports_cache_track_performance: bool = True reports_cache_benchmark_on_startup: bool = False # ============================================================================ # DATA ENTRY MODULE - CONFIGURATION # ============================================================================ data_entry_sqlite_database_path: str = "data/receipts/receipts.db" data_entry_upload_path: str = "data/receipts/uploads" data_entry_max_upload_size_mb: int = 10 data_entry_allowed_mime_types: List[str] = [ "image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", ] # ============================================================================ # TELEGRAM MODULE - BOT CONFIGURATION # ============================================================================ telegram_bot_token: str = "" telegram_smtp_host: str = "" telegram_smtp_port: int = 587 telegram_smtp_user: str = "" telegram_smtp_password: str = "" telegram_smtp_from_email: str = "" telegram_smtp_from_name: str = "ROA2WEB" telegram_smtp_use_tls: bool = True telegram_email_max_retries: int = 3 telegram_email_retry_delay: float = 2.0 telegram_sqlite_database_path: str = "data/telegram/telegram.db" class Config: env_file = ".env" env_file_encoding = "utf-8" extra = "ignore" case_sensitive = False # ============================================================================ # COMPUTED PROPERTIES # ============================================================================ @property def oracle_dsn(self) -> str: """Get Oracle DSN string.""" return f"{self.oracle_host}:{self.oracle_port}/{self.oracle_sid}" @property def cors_origins_list(self) -> List[str]: """Get CORS origins as list.""" return [origin.strip() for origin in self.cors_origins.split(",")] # Data Entry properties @property def data_entry_database_url(self) -> str: """Get SQLite database URL for async (Data Entry).""" # Resolve to absolute path for Windows/IIS compatibility abs_path = Path(self.data_entry_sqlite_database_path).resolve() return f"sqlite+aiosqlite:///{abs_path}" @property def data_entry_sync_database_url(self) -> str: """Get SQLite database URL for sync operations (Alembic).""" # Resolve to absolute path for Windows/IIS compatibility abs_path = Path(self.data_entry_sqlite_database_path).resolve() return f"sqlite:///{abs_path}" @property def data_entry_upload_path_resolved(self) -> Path: """Get resolved upload path.""" path = Path(self.data_entry_upload_path) path.mkdir(parents=True, exist_ok=True) return path @property def data_entry_max_upload_size_bytes(self) -> int: """Get max upload size in bytes.""" return self.data_entry_max_upload_size_mb * 1024 * 1024 # Reports cache properties @property def reports_cache_sqlite_path_resolved(self) -> Path: """Get resolved cache SQLite path.""" path = Path(self.reports_cache_sqlite_path) path.parent.mkdir(parents=True, exist_ok=True) return path # Telegram properties @property def telegram_sqlite_path_resolved(self) -> Path: """Get resolved Telegram SQLite path.""" path = Path(self.telegram_sqlite_database_path) path.parent.mkdir(parents=True, exist_ok=True) return path @lru_cache() def get_settings() -> UnifiedSettings: """Get cached settings instance.""" return UnifiedSettings() # Convenience instance settings = get_settings()