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>
363 lines
13 KiB
Python
363 lines
13 KiB
Python
"""
|
|
Email-Server Cache for Multi-Oracle Auto-Discovery
|
|
|
|
Builds and maintains a cache mapping emails to server IDs:
|
|
- At startup, connects to each Oracle server and extracts emails from CONTAFIN_ORACLE.UTILIZATORI
|
|
- Cache structure: {email: [server_ids]}
|
|
- Auto-refresh every 15 minutes (configurable)
|
|
- Thread-safe with asyncio.Lock
|
|
|
|
US-003: Auto-Discovery Email-Server Cache
|
|
US-013: Added username lookup support (direct query, no caching)
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional, Set
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EmailServerCache:
|
|
"""
|
|
Cache for email-to-server mapping.
|
|
|
|
Builds a dictionary {email: [server_ids]} by querying CONTAFIN_ORACLE.UTILIZATORI
|
|
on each configured Oracle server.
|
|
|
|
Features:
|
|
- Lazy initialization (build on first access or explicit call)
|
|
- Auto-refresh at configurable intervals
|
|
- Thread-safe operations
|
|
- Graceful handling of server connection failures
|
|
"""
|
|
|
|
_instance: Optional['EmailServerCache'] = None
|
|
|
|
def __new__(cls):
|
|
if cls._instance is None:
|
|
cls._instance = super(EmailServerCache, cls).__new__(cls)
|
|
cls._instance._cache: Dict[str, List[str]] = {}
|
|
cls._instance._last_refresh: Optional[datetime] = None
|
|
cls._instance._refresh_interval = timedelta(minutes=15)
|
|
cls._instance._lock = asyncio.Lock()
|
|
cls._instance._initialized = False
|
|
cls._instance._refresh_task: Optional[asyncio.Task] = None
|
|
return cls._instance
|
|
|
|
def set_refresh_interval(self, minutes: int) -> None:
|
|
"""
|
|
Set the cache refresh interval.
|
|
|
|
Args:
|
|
minutes: Refresh interval in minutes (default: 15)
|
|
"""
|
|
self._refresh_interval = timedelta(minutes=minutes)
|
|
logger.info(f"Email cache refresh interval set to {minutes} minutes")
|
|
|
|
async def build_cache(self) -> None:
|
|
"""
|
|
Build the email-server cache by querying all configured Oracle servers.
|
|
|
|
Connects to each server and extracts active user emails from
|
|
CONTAFIN_ORACLE.UTILIZATORI table.
|
|
"""
|
|
from shared.database.oracle_pool import oracle_pool
|
|
from backend.config import settings
|
|
|
|
async with self._lock:
|
|
logger.info("[EMAIL-CACHE] Building email-server cache...")
|
|
new_cache: Dict[str, Set[str]] = {} # Use set to avoid duplicates
|
|
|
|
servers = settings.get_oracle_servers()
|
|
if not servers:
|
|
logger.warning("[EMAIL-CACHE] No Oracle servers configured")
|
|
self._cache = {}
|
|
self._last_refresh = datetime.now()
|
|
self._initialized = True
|
|
return
|
|
|
|
for server in servers:
|
|
try:
|
|
logger.info(f"[EMAIL-CACHE] Querying server '{server.id}' ({server.name})...")
|
|
|
|
# Get connection from the multi-pool
|
|
async with oracle_pool.get_connection(server.id) as connection:
|
|
with connection.cursor() as cursor:
|
|
# Query emails from UTILIZATORI table
|
|
# Only active users (INACTIV=0, STERS=0) with valid emails
|
|
cursor.execute("""
|
|
SELECT LOWER(EMAIL) as email
|
|
FROM CONTAFIN_ORACLE.UTILIZATORI
|
|
WHERE EMAIL IS NOT NULL
|
|
AND TRIM(EMAIL) IS NOT NULL
|
|
AND INACTIV = 0
|
|
AND STERS = 0
|
|
""")
|
|
|
|
rows = cursor.fetchall()
|
|
email_count = 0
|
|
|
|
for row in rows:
|
|
email = row[0].strip().lower() if row[0] else None
|
|
if email and '@' in email: # Basic email validation
|
|
if email not in new_cache:
|
|
new_cache[email] = set()
|
|
new_cache[email].add(server.id)
|
|
email_count += 1
|
|
|
|
logger.info(f"[EMAIL-CACHE] Found {email_count} valid emails on server '{server.id}'")
|
|
|
|
except Exception as e:
|
|
# Log error but continue with other servers
|
|
logger.error(f"[EMAIL-CACHE] Failed to query server '{server.id}': {e}")
|
|
continue
|
|
|
|
# Convert sets to sorted lists for consistent ordering
|
|
self._cache = {email: sorted(list(server_ids)) for email, server_ids in new_cache.items()}
|
|
self._last_refresh = datetime.now()
|
|
self._initialized = True
|
|
|
|
total_emails = len(self._cache)
|
|
multi_server_emails = sum(1 for servers in self._cache.values() if len(servers) > 1)
|
|
|
|
logger.info(f"[EMAIL-CACHE] ✅ Cache built: {total_emails} unique emails")
|
|
logger.info(f"[EMAIL-CACHE] {multi_server_emails} emails exist on multiple servers")
|
|
|
|
async def refresh_if_needed(self) -> bool:
|
|
"""
|
|
Refresh cache if the refresh interval has passed.
|
|
|
|
Returns:
|
|
True if cache was refreshed, False otherwise
|
|
"""
|
|
if not self._initialized:
|
|
await self.build_cache()
|
|
return True
|
|
|
|
if self._last_refresh is None:
|
|
await self.build_cache()
|
|
return True
|
|
|
|
time_since_refresh = datetime.now() - self._last_refresh
|
|
if time_since_refresh >= self._refresh_interval:
|
|
await self.build_cache()
|
|
return True
|
|
|
|
return False
|
|
|
|
def get_servers_for_email(self, email: str) -> List[str]:
|
|
"""
|
|
Get list of server IDs where the email exists.
|
|
|
|
Args:
|
|
email: User email address
|
|
|
|
Returns:
|
|
List of server_ids where this email exists.
|
|
Empty list if email not found (NOT an error).
|
|
"""
|
|
if not email:
|
|
return []
|
|
|
|
normalized_email = email.strip().lower()
|
|
servers = self._cache.get(normalized_email, [])
|
|
|
|
if servers:
|
|
logger.debug(f"[EMAIL-CACHE] Email '{normalized_email}' found on servers: {servers}")
|
|
else:
|
|
logger.debug(f"[EMAIL-CACHE] Email '{normalized_email}' not found in cache")
|
|
|
|
return servers.copy() # Return a copy to prevent external modification
|
|
|
|
def is_initialized(self) -> bool:
|
|
"""Check if cache has been built at least once."""
|
|
return self._initialized
|
|
|
|
def get_cache_stats(self) -> Dict:
|
|
"""
|
|
Get cache statistics.
|
|
|
|
Returns:
|
|
Dict with cache stats (total_emails, multi_server_count, last_refresh, etc.)
|
|
"""
|
|
if not self._initialized:
|
|
return {
|
|
'initialized': False,
|
|
'total_emails': 0,
|
|
'last_refresh': None,
|
|
'refresh_interval_minutes': self._refresh_interval.total_seconds() / 60
|
|
}
|
|
|
|
multi_server = sum(1 for servers in self._cache.values() if len(servers) > 1)
|
|
server_distribution = {}
|
|
for servers in self._cache.values():
|
|
count = len(servers)
|
|
server_distribution[count] = server_distribution.get(count, 0) + 1
|
|
|
|
return {
|
|
'initialized': True,
|
|
'total_emails': len(self._cache),
|
|
'multi_server_count': multi_server,
|
|
'server_distribution': server_distribution,
|
|
'last_refresh': self._last_refresh.isoformat() if self._last_refresh else None,
|
|
'refresh_interval_minutes': self._refresh_interval.total_seconds() / 60
|
|
}
|
|
|
|
async def start_auto_refresh(self) -> None:
|
|
"""
|
|
Start background task for automatic cache refresh.
|
|
|
|
Runs refresh at the configured interval (default: 15 minutes).
|
|
"""
|
|
if self._refresh_task and not self._refresh_task.done():
|
|
logger.warning("[EMAIL-CACHE] Auto-refresh task already running")
|
|
return
|
|
|
|
async def refresh_loop():
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(self._refresh_interval.total_seconds())
|
|
logger.info("[EMAIL-CACHE] Auto-refresh triggered")
|
|
await self.build_cache()
|
|
except asyncio.CancelledError:
|
|
logger.info("[EMAIL-CACHE] Auto-refresh task cancelled")
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"[EMAIL-CACHE] Auto-refresh error: {e}")
|
|
# Continue running, will retry on next interval
|
|
|
|
self._refresh_task = asyncio.create_task(refresh_loop())
|
|
logger.info(f"[EMAIL-CACHE] Auto-refresh started (every {self._refresh_interval.total_seconds() / 60:.0f} minutes)")
|
|
|
|
async def stop_auto_refresh(self) -> None:
|
|
"""Stop the auto-refresh background task."""
|
|
if self._refresh_task and not self._refresh_task.done():
|
|
self._refresh_task.cancel()
|
|
try:
|
|
await self._refresh_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._refresh_task = None
|
|
logger.info("[EMAIL-CACHE] Auto-refresh stopped")
|
|
|
|
def clear_cache(self) -> None:
|
|
"""Clear the cache (useful for testing)."""
|
|
self._cache = {}
|
|
self._initialized = False
|
|
self._last_refresh = None
|
|
logger.info("[EMAIL-CACHE] Cache cleared")
|
|
|
|
async def get_servers_for_username(self, username: str) -> List[str]:
|
|
"""
|
|
Get list of server IDs where the username exists (US-013).
|
|
|
|
Unlike email lookup which uses the cache, username lookup queries
|
|
Oracle directly on each server. This is because:
|
|
- Usernames are less commonly used for login
|
|
- Direct query ensures fresh data
|
|
- Avoids bloating the cache with both email and username mappings
|
|
|
|
Args:
|
|
username: Username to look up (case-insensitive, converted to uppercase)
|
|
|
|
Returns:
|
|
List of server_ids where this username exists.
|
|
Empty list if username not found (NOT an error).
|
|
"""
|
|
if not username:
|
|
return []
|
|
|
|
from shared.database.oracle_pool import oracle_pool
|
|
from backend.config import settings
|
|
|
|
normalized_username = username.strip().upper()
|
|
found_servers: List[str] = []
|
|
|
|
servers = settings.get_oracle_servers()
|
|
if not servers:
|
|
logger.warning("[EMAIL-CACHE] No Oracle servers configured for username lookup")
|
|
return []
|
|
|
|
for server in servers:
|
|
try:
|
|
async with oracle_pool.get_connection(server.id) as connection:
|
|
with connection.cursor() as cursor:
|
|
# Query for username in UTILIZATORI table
|
|
# Only active users (INACTIV=0, STERS=0)
|
|
cursor.execute("""
|
|
SELECT 1
|
|
FROM CONTAFIN_ORACLE.UTILIZATORI
|
|
WHERE UPPER(UTILIZATOR) = :username
|
|
AND INACTIV = 0
|
|
AND STERS = 0
|
|
AND ROWNUM = 1
|
|
""", {"username": normalized_username})
|
|
|
|
row = cursor.fetchone()
|
|
if row:
|
|
found_servers.append(server.id)
|
|
logger.debug(f"[EMAIL-CACHE] Username '{normalized_username}' found on server '{server.id}'")
|
|
|
|
except Exception as e:
|
|
logger.error(f"[EMAIL-CACHE] Failed to query username on server '{server.id}': {e}")
|
|
continue
|
|
|
|
if found_servers:
|
|
logger.info(f"[EMAIL-CACHE] Username '{normalized_username}' found on {len(found_servers)} server(s): {found_servers}")
|
|
else:
|
|
logger.debug(f"[EMAIL-CACHE] Username '{normalized_username}' not found on any server")
|
|
|
|
return sorted(found_servers)
|
|
|
|
|
|
# Global singleton instance
|
|
email_server_cache = EmailServerCache()
|
|
|
|
|
|
# Convenience functions for external use
|
|
def get_servers_for_email(email: str) -> List[str]:
|
|
"""
|
|
Get list of server IDs where the email exists.
|
|
|
|
This is a convenience function that wraps the singleton instance.
|
|
|
|
Args:
|
|
email: User email address
|
|
|
|
Returns:
|
|
List of server_ids. Empty list if email not found (NOT an error).
|
|
"""
|
|
return email_server_cache.get_servers_for_email(email)
|
|
|
|
|
|
async def build_email_cache() -> None:
|
|
"""Build/refresh the email-server cache."""
|
|
await email_server_cache.build_cache()
|
|
|
|
|
|
async def start_email_cache_refresh() -> None:
|
|
"""Start automatic cache refresh."""
|
|
await email_server_cache.start_auto_refresh()
|
|
|
|
|
|
async def stop_email_cache_refresh() -> None:
|
|
"""Stop automatic cache refresh."""
|
|
await email_server_cache.stop_auto_refresh()
|
|
|
|
|
|
async def get_servers_for_username(username: str) -> List[str]:
|
|
"""
|
|
Get list of server IDs where the username exists (US-013).
|
|
|
|
This is a convenience function that wraps the singleton instance.
|
|
|
|
Args:
|
|
username: Username to look up (case-insensitive)
|
|
|
|
Returns:
|
|
List of server_ids. Empty list if username not found (NOT an error).
|
|
"""
|
|
return await email_server_cache.get_servers_for_username(username)
|