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:
jtricerolph 2026-07-04 19:36:41 +00:00
parent 851747b561
commit aeb99650bd
10 changed files with 226 additions and 230 deletions

View 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"))

View file

@ -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")

View file

@ -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'),