Files
Claude Agent b137e80b71 feat: multi-Oracle server support with runtime switching
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>
2026-01-26 22:39:06 +00:00

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()