NewBook credentials from central Settings service
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 <noreply@anthropic.com>
This commit is contained in:
parent
851747b561
commit
aeb99650bd
10 changed files with 226 additions and 230 deletions
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
100
backend/services/central_settings.py
Normal file
100
backend/services/central_settings.py
Normal file
|
|
@ -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"))
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue