From aeb99650bd4d3da95362f24f5de80d47cbffc027 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sat, 4 Jul 2026 19:36:41 +0000 Subject: [PATCH] NewBook credentials from central Settings service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stack-wide NewBook config lives in the Settings app (LXC 116) and is fetched live via SETTINGS_URL/SETTINGS_SECRET — same pattern as cashup, room-planner and maintenance. App-local system_config credentials remain as a fallback for standalone/dev use. The app's Settings → Newbook page no longer edits credentials; it points to the central app and keeps Test Connection. Co-Authored-By: Claude Sonnet 4.6 --- backend/api/bookability.py | 24 ++- backend/api/config.py | 17 +- backend/api/sync_bookings.py | 4 +- backend/jobs/data_sync.py | 10 +- backend/jobs/fetch_current_rates.py | 59 ++++--- backend/services/central_settings.py | 100 +++++++++++ backend/services/newbook_client.py | 12 +- backend/services/newbook_rates_client.py | 12 +- docker-compose.yml | 6 +- frontend/src/pages/Settings.tsx | 212 +++-------------------- 10 files changed, 226 insertions(+), 230 deletions(-) create mode 100644 backend/services/central_settings.py diff --git a/backend/api/bookability.py b/backend/api/bookability.py index c94d452..5197666 100644 --- a/backend/api/bookability.py +++ b/backend/api/bookability.py @@ -496,9 +496,19 @@ def _refresh_date_sync(rate_date: date): ) config = {row.config_key: row.config_value for row in config_result.fetchall()} - if not all(k in config for k in ['newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region']): - logger.error("Newbook credentials not configured for single-date refresh") - return + # Central Settings service first, app-local config fallback + from services.central_settings import get_newbook_credentials_sync + creds = get_newbook_credentials_sync() + if not creds: + if not all(k in config for k in ['newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region']): + logger.error("Newbook credentials not configured for single-date refresh") + return + creds = { + 'api_key': config['newbook_api_key'], + 'username': config['newbook_username'], + 'password': config['newbook_password'], + 'region': config['newbook_region'], + } vat_rate = Decimal(config.get('accommodation_vat_rate', '0.20')) @@ -509,10 +519,10 @@ def _refresh_date_sync(rate_date: date): included_categories = set(row.site_id for row in cat_result.fetchall()) client = NewbookRatesClient( - api_key=config['newbook_api_key'], - username=config['newbook_username'], - password=config['newbook_password'], - region=config['newbook_region'], + api_key=creds['api_key'], + username=creds['username'], + password=creds['password'], + region=creds['region'], vat_rate=vat_rate ) diff --git a/backend/api/config.py b/backend/api/config.py index d5c0dc1..fa4a889 100644 --- a/backend/api/config.py +++ b/backend/api/config.py @@ -747,10 +747,19 @@ async def _test_newbook(db: AsyncSession): logger = logging.getLogger(__name__) - api_key = await _get_config_value(db, "newbook_api_key") - username = await _get_config_value(db, "newbook_username") - password = await _get_config_value(db, "newbook_password") - region = await _get_config_value(db, "newbook_region") + # Central Settings service first, app-local config fallback + from services.central_settings import get_newbook_credentials + central = await get_newbook_credentials() + if central: + api_key = central["api_key"] + username = central["username"] + password = central["password"] + region = central["region"] + else: + api_key = await _get_config_value(db, "newbook_api_key") + username = await _get_config_value(db, "newbook_username") + password = await _get_config_value(db, "newbook_password") + region = await _get_config_value(db, "newbook_region") # Log what we have (masked) logger.info(f"Testing Newbook: api_key={'set' if api_key else 'empty'}, username={username}, region={region}") diff --git a/backend/api/sync_bookings.py b/backend/api/sync_bookings.py index d3396b6..9ea42ff 100644 --- a/backend/api/sync_bookings.py +++ b/backend/api/sync_bookings.py @@ -309,7 +309,9 @@ def run_bookings_data_sync( return row.config_value return None - creds = { + # Central Settings service first, app-local config fallback + from services.central_settings import get_newbook_credentials_sync + creds = get_newbook_credentials_sync() or { 'api_key': get_config('newbook_api_key'), 'username': get_config('newbook_username'), 'password': get_config('newbook_password'), diff --git a/backend/jobs/data_sync.py b/backend/jobs/data_sync.py index e6cb605..4697a94 100644 --- a/backend/jobs/data_sync.py +++ b/backend/jobs/data_sync.py @@ -25,7 +25,15 @@ def get_config_value(db, key: str) -> Optional[str]: def load_newbook_credentials(db) -> dict: - """Load Newbook API credentials from database config.""" + """ + Load Newbook API credentials — central Settings service first (stack-wide + config), falling back to the app-local database config. + """ + from services.central_settings import get_newbook_credentials_sync + central = get_newbook_credentials_sync() + if central: + return central + import base64 def decrypt(value: str) -> str: diff --git a/backend/jobs/fetch_current_rates.py b/backend/jobs/fetch_current_rates.py index 6c10a75..8c7d5ff 100644 --- a/backend/jobs/fetch_current_rates.py +++ b/backend/jobs/fetch_current_rates.py @@ -225,34 +225,45 @@ async def run_fetch_current_rates(horizon_days: int = 720, start_date: date = No import base64 from services.newbook_rates_client import NewbookRatesClient - # Get credentials from config (decrypt encrypted values) - config_result = db.execute( - text(""" - SELECT config_key, config_value, COALESCE(is_encrypted, false) as is_encrypted - FROM system_config - WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region') - """) - ) - config = {} - for row in config_result.fetchall(): - value = row.config_value - if row.is_encrypted and value: - try: - value = base64.b64decode(value.encode()).decode() - except Exception: - pass # Use raw value if decryption fails - config[row.config_key] = value + # Credentials: central Settings service first, app-local config fallback + from services.central_settings import get_newbook_credentials_sync + creds = get_newbook_credentials_sync() - if not all(k in config for k in ['newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region']): - logger.error("Newbook credentials not configured") - return + if not creds: + config_result = db.execute( + text(""" + SELECT config_key, config_value, COALESCE(is_encrypted, false) as is_encrypted + FROM system_config + WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region') + """) + ) + config = {} + for row in config_result.fetchall(): + value = row.config_value + if row.is_encrypted and value: + try: + value = base64.b64decode(value.encode()).decode() + except Exception: + pass # Use raw value if decryption fails + config[row.config_key] = value + + if not all(k in config for k in ['newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region']): + logger.error("Newbook credentials not configured") + return + + creds = { + 'api_key': config['newbook_api_key'], + 'username': config['newbook_username'], + 'password': config['newbook_password'], + 'region': config['newbook_region'], + } # Create client client = NewbookRatesClient( - api_key=config['newbook_api_key'], - username=config['newbook_username'], - password=config['newbook_password'], - region=config['newbook_region'], + api_key=creds['api_key'], + username=creds['username'], + password=creds['password'], + region=creds['region'], vat_rate=Decimal(vat_rate_str) ) diff --git a/backend/services/central_settings.py b/backend/services/central_settings.py new file mode 100644 index 0000000..6814b09 --- /dev/null +++ b/backend/services/central_settings.py @@ -0,0 +1,100 @@ +""" +Client for the stack's central Settings service. + +NewBook credentials are managed once in the Settings app (LXC 116) and +fetched live by every app — the same pattern as cashup / room-planner / +maintenance (see their lib/newbook.js). Falls back to None if the service +is unreachable so callers can fall back to app-local config. +""" +import logging +import os +import time +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +SETTINGS_URL = os.getenv("SETTINGS_URL", "") +SETTINGS_SECRET = os.getenv("SETTINGS_SECRET", "") + +_CACHE_TTL = 60 # seconds — credentials change rarely; avoid hammering the service +_cache: dict = {} + + +async def get_integration(name: str) -> Optional[dict]: + """ + Fetch integration config (e.g. 'newbook') from the central Settings + service. Returns the config dict, or None if unavailable/unconfigured. + """ + if not SETTINGS_URL or not SETTINGS_SECRET: + return None + + cached = _cache.get(name) + if cached and (time.monotonic() - cached[0]) < _CACHE_TTL: + return cached[1] + + url = f"{SETTINGS_URL}/settings/api/internal/integration/{name}" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get( + url, headers={"Authorization": f"Bearer {SETTINGS_SECRET}"} + ) + resp.raise_for_status() + data = resp.json() + _cache[name] = (time.monotonic(), data) + return data + except Exception as e: + logger.warning(f"Central settings fetch failed for '{name}': {e}") + return None + + +def _extract_newbook(s: Optional[dict]) -> Optional[dict]: + if not s: + return None + creds = { + "api_key": s.get("api_key") or "", + "username": s.get("username") or "", + "password": s.get("password") or "", + "region": s.get("region") or "eu", + } + # Only usable if the essential fields are present + if not (creds["api_key"] and creds["username"] and creds["password"]): + return None + return creds + + +async def get_newbook_credentials() -> Optional[dict]: + """ + Returns {'api_key', 'username', 'password', 'region'} from central + settings, or None if not available (caller should fall back). + """ + return _extract_newbook(await get_integration("newbook")) + + +def get_integration_sync(name: str) -> Optional[dict]: + """Blocking variant of get_integration for sync job contexts.""" + if not SETTINGS_URL or not SETTINGS_SECRET: + return None + + cached = _cache.get(name) + if cached and (time.monotonic() - cached[0]) < _CACHE_TTL: + return cached[1] + + url = f"{SETTINGS_URL}/settings/api/internal/integration/{name}" + try: + resp = httpx.get( + url, headers={"Authorization": f"Bearer {SETTINGS_SECRET}"}, timeout=5.0 + ) + resp.raise_for_status() + data = resp.json() + _cache[name] = (time.monotonic(), data) + return data + except Exception as e: + logger.warning(f"Central settings fetch failed for '{name}': {e}") + return None + + +def get_newbook_credentials_sync() -> Optional[dict]: + """Blocking variant of get_newbook_credentials for sync job contexts.""" + return _extract_newbook(get_integration_sync("newbook")) diff --git a/backend/services/newbook_client.py b/backend/services/newbook_client.py index 85f2fe6..f19ff96 100644 --- a/backend/services/newbook_client.py +++ b/backend/services/newbook_client.py @@ -48,7 +48,17 @@ class NewbookClient: @classmethod async def from_db(cls, db): - """Create client with credentials from database""" + """ + Create client with credentials from the central Settings service + (stack-wide NewBook config), falling back to the app-local + system_config table for standalone/dev use. + """ + from services.central_settings import get_newbook_credentials + + central = await get_newbook_credentials() + if central: + return cls(**central) + from api.config import _get_config_value api_key = await _get_config_value(db, "newbook_api_key") diff --git a/backend/services/newbook_rates_client.py b/backend/services/newbook_rates_client.py index caa2cca..64a1e2d 100644 --- a/backend/services/newbook_rates_client.py +++ b/backend/services/newbook_rates_client.py @@ -51,10 +51,14 @@ class NewbookRatesClient: @classmethod async def from_db(cls, db): - """Create client with credentials and VAT rate from database""" + """ + Create client with credentials from the central Settings service + (stack-wide NewBook config), falling back to the app-local + system_config table. VAT rate stays app-local either way. + """ from sqlalchemy import text + from services.central_settings import get_newbook_credentials - # Get credentials from config result = await db.execute( text("SELECT config_key, config_value FROM system_config WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region', 'accommodation_vat_rate')") ) @@ -63,6 +67,10 @@ class NewbookRatesClient: vat_rate = Decimal(config.get('accommodation_vat_rate', '0.20')) + central = await get_newbook_credentials() + if central: + return cls(**central, vat_rate=vat_rate) + return cls( api_key=config.get('newbook_api_key'), username=config.get('newbook_username'), diff --git a/docker-compose.yml b/docker-compose.yml index 542e2f0..41e09d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,10 +7,8 @@ services: - DATABASE_URL=${DATABASE_URL} - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} - APP_SLUG=forecasting - - NEWBOOK_API_KEY=${NEWBOOK_API_KEY:-} - - NEWBOOK_USERNAME=${NEWBOOK_USERNAME:-} - - NEWBOOK_PASSWORD=${NEWBOOK_PASSWORD:-} - - NEWBOOK_REGION=${NEWBOOK_REGION:-AU} + - SETTINGS_URL=${SETTINGS_URL:-} + - SETTINGS_SECRET=${SETTINGS_SECRET:-} - RESOS_API_KEY=${RESOS_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} volumes: diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index f235d5b..ee1016a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2024,61 +2024,9 @@ const CurrentRatesDataSyncSection: React.FC = () => { } const NewbookPage: React.FC = () => { - const [apiKey, setApiKey] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [region, setRegion] = useState('') - const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle') const [testMessage, setTestMessage] = useState('') - // Fetch current settings - const { data: settings, isLoading } = useQuery({ - queryKey: ['newbook-settings'], - queryFn: async () => { - const response = await fetch('/forecasting/api/config/settings/newbook') - if (!response.ok) throw new Error('Failed to fetch settings') - return response.json() as Promise - }, - staleTime: 30000, - }) - - // Populate form when settings load - React.useEffect(() => { - if (settings) { - setUsername(settings.newbook_username || '') - setRegion(settings.newbook_region || '') - // Don't populate password/api_key - they're masked - } - }, [settings]) - - const handleSave = async () => { - setSaveStatus('saving') - try { - const response = await fetch('/forecasting/api/config/settings/newbook', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - newbook_api_key: apiKey || undefined, - newbook_username: username || undefined, - newbook_password: password || undefined, - newbook_region: region || undefined, - }) - }) - if (!response.ok) throw new Error('Failed to save') - setSaveStatus('success') - // Clear password fields after save - setApiKey('') - setPassword('') - setTimeout(() => setSaveStatus('idle'), 3000) - } catch { - setSaveStatus('error') - setTimeout(() => setSaveStatus('idle'), 3000) - } - } - const handleTestConnection = async () => { setTestStatus('testing') setTestMessage('') @@ -2104,146 +2052,38 @@ const NewbookPage: React.FC = () => { }, 5000) } - if (isLoading) { - return ( -
-
Loading settings...
-
- ) - } - return (

Newbook Settings

-

Configure your Newbook API connection for hotel data synchronization.

+

Newbook data synchronization for this app.

-
- {/* Left side - API Configuration */} -
-

API Configuration

- -
- - - - - - - - -
- - -
- - {testMessage && ( -
- {testMessage} -
- )} -
-
- - {/* Right side - Connection Status */} -
-

Connection Status

-
-
- API Key - - {settings?.newbook_api_key_set ? 'Configured' : 'Not set'} - -
-
- Username - - {settings?.newbook_username || 'Not set'} - -
-
- Password - - {settings?.newbook_password_set ? 'Configured' : 'Not set'} - -
-
- Region - - {settings?.newbook_region || 'Not set'} - -
-
-
+
+ Newbook API credentials are managed centrally in the stack Settings app + (Integrations → NewBook) and shared by all apps. Use the button below to verify this + app can reach Newbook with those credentials.
+
+ +
+ + {testMessage && ( +
+ {testMessage} +
+ )} +