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>
This commit is contained in:
@@ -14,7 +14,7 @@ Usage:
|
||||
import logging
|
||||
from typing import Optional, Callable, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from auth.dependencies import get_current_user
|
||||
from auth.models import CurrentUser
|
||||
@@ -51,9 +51,14 @@ def create_calendar_router(
|
||||
)
|
||||
|
||||
# Helper to get schema for company
|
||||
async def _get_schema_for_company(company_id: int) -> Optional[str]:
|
||||
"""Get Oracle schema for company ID."""
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
async def _get_schema_for_company(company_id: int, server_id: Optional[str] = None) -> Optional[str]:
|
||||
"""Get Oracle schema for company ID.
|
||||
|
||||
Args:
|
||||
company_id: The company ID to get schema for
|
||||
server_id: The Oracle server ID (for multi-server mode)
|
||||
"""
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT SCHEMA FROM CONTAFIN_ORACLE.V_NOM_FIRME
|
||||
@@ -63,22 +68,28 @@ def create_calendar_router(
|
||||
return result[0] if result else None
|
||||
|
||||
# Apply cache to schema lookup if decorator provided
|
||||
# Include server_id in cache key for multi-server mode
|
||||
if cache_decorator:
|
||||
_get_schema_for_company = cache_decorator(
|
||||
cache_type='schema',
|
||||
key_params=['company_id']
|
||||
key_params=['company_id', 'server_id']
|
||||
)(_get_schema_for_company)
|
||||
|
||||
# Helper to get periods - can be cached
|
||||
async def _get_available_periods(company_id: int) -> CalendarPeriodsResponse:
|
||||
"""Get available accounting periods for a company."""
|
||||
schema = await _get_schema_for_company(company_id)
|
||||
async def _get_available_periods(company_id: int, server_id: Optional[str] = None) -> CalendarPeriodsResponse:
|
||||
"""Get available accounting periods for a company.
|
||||
|
||||
Args:
|
||||
company_id: The company ID to get periods for
|
||||
server_id: The Oracle server ID (for multi-server mode)
|
||||
"""
|
||||
schema = await _get_schema_for_company(company_id, server_id)
|
||||
if not schema:
|
||||
logger.warning(f"Schema not found for company {company_id}")
|
||||
return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0)
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(f"""
|
||||
SELECT ANUL, LUNA
|
||||
@@ -112,14 +123,16 @@ def create_calendar_router(
|
||||
return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0)
|
||||
|
||||
# Apply cache decorator if provided
|
||||
# Include server_id in cache key for multi-server mode
|
||||
if cache_decorator:
|
||||
_get_available_periods = cache_decorator(
|
||||
cache_type='calendar_periods',
|
||||
key_params=['company_id']
|
||||
key_params=['company_id', 'server_id']
|
||||
)(_get_available_periods)
|
||||
|
||||
@router.get("/periods", response_model=CalendarPeriodsResponse)
|
||||
async def get_calendar_periods(
|
||||
request: Request,
|
||||
company: int = Query(..., description="Company ID"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> CalendarPeriodsResponse:
|
||||
@@ -131,6 +144,8 @@ def create_calendar_router(
|
||||
if str(company) not in current_user.companies:
|
||||
raise HTTPException(403, f"Nu aveți acces la firma {company}")
|
||||
|
||||
return await _get_available_periods(company)
|
||||
# Get server_id from request state (injected by auth middleware from JWT)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
return await _get_available_periods(company, server_id)
|
||||
|
||||
return router
|
||||
|
||||
@@ -45,13 +45,17 @@ def create_companies_router(
|
||||
)
|
||||
|
||||
# Helper function to get companies - can be cached
|
||||
async def _get_user_companies_data(username: str) -> List[Company]:
|
||||
async def _get_user_companies_data(username: str, server_id: Optional[str] = None) -> List[Company]:
|
||||
"""
|
||||
Get list of companies for a user from Oracle.
|
||||
|
||||
Args:
|
||||
username: The username to get companies for
|
||||
server_id: The Oracle server ID (for multi-server mode)
|
||||
"""
|
||||
companies = []
|
||||
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
try:
|
||||
# Get user ID
|
||||
@@ -97,10 +101,11 @@ def create_companies_router(
|
||||
return companies
|
||||
|
||||
# Apply cache decorator if provided
|
||||
# Include server_id in cache key for multi-server mode
|
||||
if cache_decorator:
|
||||
_get_user_companies_data = cache_decorator(
|
||||
cache_type='companies',
|
||||
key_params=['username']
|
||||
key_params=['username', 'server_id']
|
||||
)(_get_user_companies_data)
|
||||
|
||||
@router.get("", response_model=CompanyListResponse)
|
||||
@@ -111,7 +116,9 @@ def create_companies_router(
|
||||
):
|
||||
"""Get list of companies the user has access to."""
|
||||
try:
|
||||
companies = await _get_user_companies_data(current_user.username)
|
||||
# Get server_id from request state (injected by auth middleware from JWT)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
companies = await _get_user_companies_data(current_user.username, server_id)
|
||||
|
||||
return CompanyListResponse(
|
||||
companies=companies,
|
||||
@@ -124,6 +131,7 @@ def create_companies_router(
|
||||
@router.get("/{company_id}", response_model=Company)
|
||||
async def get_company_details(
|
||||
company_id: str,
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""Get details of a specific company."""
|
||||
@@ -132,7 +140,9 @@ def create_companies_router(
|
||||
raise HTTPException(403, f"Nu aveți acces la firma {company_id}")
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
# Get server_id from request state (injected by auth middleware from JWT)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT ID_FIRMA, FIRMA, SCHEMA, COD_FISCAL
|
||||
|
||||
@@ -13,6 +13,12 @@ from pydantic import BaseModel
|
||||
from shared.auth.dependencies import get_current_user, CurrentUser
|
||||
|
||||
|
||||
class AuthModeResponse(BaseModel):
|
||||
"""Response for auth mode endpoint."""
|
||||
mode: str # "single-server" or "multi-server"
|
||||
supports_email_login: bool # True if email-based login is available
|
||||
|
||||
|
||||
class LogEntry(BaseModel):
|
||||
"""Single log entry."""
|
||||
line: str
|
||||
@@ -36,6 +42,36 @@ def create_system_router() -> APIRouter:
|
||||
"""
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/auth-mode", response_model=AuthModeResponse)
|
||||
async def get_auth_mode():
|
||||
"""
|
||||
Get the authentication mode configuration.
|
||||
|
||||
This is a PUBLIC endpoint (no auth required) that tells the frontend
|
||||
whether to use the email-based multi-server login flow or the classic
|
||||
username/password single-server flow.
|
||||
|
||||
Returns:
|
||||
- mode: "single-server" for legacy config, "multi-server" for ORACLE_SERVERS
|
||||
- supports_email_login: True only in multi-server mode with email cache
|
||||
"""
|
||||
from backend.config import settings
|
||||
|
||||
servers = settings.get_oracle_servers()
|
||||
|
||||
# Multi-server mode: 2+ servers configured via ORACLE_SERVERS
|
||||
if servers and len(servers) > 1:
|
||||
return AuthModeResponse(
|
||||
mode="multi-server",
|
||||
supports_email_login=True
|
||||
)
|
||||
|
||||
# Single-server mode: legacy config or single ORACLE_SERVERS entry
|
||||
return AuthModeResponse(
|
||||
mode="single-server",
|
||||
supports_email_login=False
|
||||
)
|
||||
|
||||
def get_logs_path() -> Path:
|
||||
"""Get logs directory path based on environment."""
|
||||
# Windows production: C:\inetpub\wwwroot\roa2web\logs
|
||||
|
||||
Reference in New Issue
Block a user