Complete implementation of multi-server Oracle database support: Backend: - Multi-pool Oracle with lazy loading per server - Email-to-server cache for automatic server discovery - JWT tokens include server_id claim - /auth/check-identity and /auth/check-email endpoints - /auth/my-servers endpoint for listing user's accessible servers - Server switch with password re-authentication Frontend: - New ServerSelector component for header dropdown - Multi-step login flow (identity → server → password) - Server switching from header with password modal - Mobile drawer menu with server selection - Dark mode support for all new components - URL bookmark support with ?server= query param Scripts: - Unified start.sh replacing start-prod.sh/start-test.sh - Unified ssh-tunnel.sh with multi-server support - Updated status.sh for new architecture Tests: - E2E tests for multi-server and single-server login flows - Backend unit tests for all new endpoints - Oracle multi-pool integration tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
297 lines
12 KiB
Python
297 lines
12 KiB
Python
"""
|
|
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()
|