Move Resos API key to central Settings service; add sidebar scrollbar styling

Removes the standalone resos_api_key from the forecasting app's own
system_config table. All credential fetches now go through
central_settings.get_resos_credentials() / get_resos_credentials_sync()
which pull from the Settings app (LXC 116) via the internal integration
endpoint — the same pattern already used for NewBook. The Resos API
Config section is removed from the forecasting Settings page; users
manage the key in the central Settings app instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-13 14:35:35 +00:00
parent 9057d764fa
commit 3650c92175
7 changed files with 51 additions and 282 deletions

View file

@ -137,81 +137,6 @@ async def test_newbook_settings(
return await _test_newbook(db)
# ============================================
# RESOS SETTINGS ENDPOINTS
# ============================================
class ResosSettingsResponse(BaseModel):
resos_api_key_set: bool = False
class ResosSettingsUpdate(BaseModel):
resos_api_key: Optional[str] = None
@router.get("/settings/resos", response_model=ResosSettingsResponse)
async def get_resos_settings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get Resos settings"""
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'resos_api_key'")
)
row = result.fetchone()
resos_api_key_set = bool(row and row.config_value)
return ResosSettingsResponse(resos_api_key_set=resos_api_key_set)
@router.post("/settings/resos")
async def update_resos_settings(
settings: ResosSettingsUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update Resos settings"""
# Update Resos API key (encrypted)
if settings.resos_api_key:
encrypted_key = base64.b64encode(settings.resos_api_key.encode()).decode()
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, is_encrypted, updated_at, updated_by)
VALUES ('resos_api_key', :value, true, NOW(), :user)
ON CONFLICT (config_key) DO UPDATE SET
config_value = :value,
is_encrypted = true,
updated_at = NOW(),
updated_by = :user
"""),
{"value": encrypted_key, "user": current_user['username']}
)
await db.commit()
return {"status": "saved", "message": "Resos settings updated"}
@router.post("/settings/resos/test")
async def test_resos_settings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Test Resos connection with current settings"""
try:
from services.resos_client import ResosClient
async with await ResosClient.from_db(db) as client:
if not client.api_key:
raise HTTPException(status_code=400, detail="Resos API key not configured")
success = await client.test_connection()
if success:
return {"status": "success", "message": "Connected to Resos API successfully"}
else:
raise HTTPException(status_code=400, detail="Connection failed - check API key")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Connection test failed: {str(e)}")
# ============================================
@ -840,8 +765,10 @@ async def _test_newbook(db: AsyncSession):
async def _test_resos(db: AsyncSession):
"""Test Resos API connection"""
import httpx
from services.central_settings import get_resos_credentials
api_key = await _get_config_value(db, "resos_api_key")
creds = await get_resos_credentials()
api_key = creds["api_key"] if creds else None
if not api_key:
raise HTTPException(status_code=400, detail="Resos API key not configured")

View file

@ -76,29 +76,10 @@ def load_newbook_credentials(db) -> dict:
def load_resos_credentials(db) -> dict:
"""Load Resos API credentials from database config."""
import base64
def decrypt(value: str) -> str:
"""Decrypt base64 encoded value"""
if not value:
return None
try:
return base64.b64decode(value.encode()).decode()
except:
return value
# Get API key (may be encrypted)
api_key_result = db.execute(
text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = 'resos_api_key'")
)
api_key_row = api_key_result.fetchone()
api_key = None
if api_key_row and api_key_row.config_value:
api_key = decrypt(api_key_row.config_value) if api_key_row.is_encrypted else api_key_row.config_value
return {'api_key': api_key}
"""Load Resos API credentials from central Settings service."""
from services.central_settings import get_resos_credentials_sync
creds = get_resos_credentials_sync()
return creds or {'api_key': None}
def load_gl_config(db) -> tuple:

View file

@ -5,7 +5,6 @@ Pattern: Replicates newbook bookings sync but adapted for Resos covers/stats
"""
import json
import logging
import base64
from datetime import date, datetime, timedelta
from typing import Optional, Tuple, List, Dict, Any
@ -20,27 +19,6 @@ logger = logging.getLogger(__name__)
VALID_STATUSES = ('approved', 'arrived', 'seated', 'left')
def get_config_value(db, key: str) -> Optional[str]:
"""Get a configuration value from system_config table."""
result = db.execute(
text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = :key"),
{"key": key}
)
row = result.fetchone()
if not row or not row.config_value:
return None
# Decrypt if encrypted
if row.is_encrypted:
try:
return base64.b64decode(row.config_value.encode()).decode()
except Exception as e:
logger.warning(f"Failed to decrypt {key}: {e}")
return row.config_value
return row.config_value
def load_resos_custom_field_mappings(db) -> Dict[str, Dict[str, Any]]:
"""
Load custom field mappings from resos_custom_field_mapping table.
@ -194,11 +172,13 @@ async def sync_resos_bookings_data(
)
db.commit()
# Load Resos API key
api_key = get_config_value(db, 'resos_api_key')
# Load Resos API key from central Settings service
from services.central_settings import get_resos_credentials_sync
_resos_creds = get_resos_credentials_sync()
api_key = _resos_creds["api_key"] if _resos_creds else None
if not api_key:
raise Exception("Resos API key not configured")
raise Exception("Resos API key not configured in central Settings")
# Load mappings
cf_mappings = load_resos_custom_field_mappings(db)

View file

@ -98,3 +98,22 @@ def get_integration_sync(name: str) -> Optional[dict]:
def get_newbook_credentials_sync() -> Optional[dict]:
"""Blocking variant of get_newbook_credentials for sync job contexts."""
return _extract_newbook(get_integration_sync("newbook"))
def _extract_resos(s: Optional[dict]) -> Optional[dict]:
if not s:
return None
key = s.get("api_key") or ""
if not key:
return None
return {"api_key": key}
async def get_resos_credentials() -> Optional[dict]:
"""Returns {'api_key'} from central settings, or None if not configured."""
return _extract_resos(await get_integration("resos"))
def get_resos_credentials_sync() -> Optional[dict]:
"""Blocking variant of get_resos_credentials for sync job contexts."""
return _extract_resos(get_integration_sync("resos"))

View file

@ -44,11 +44,10 @@ class ResosClient:
@classmethod
async def from_db(cls, db):
"""Create client with credentials from database"""
from api.config import _get_config_value
api_key = await _get_config_value(db, "resos_api_key")
return cls(api_key=api_key)
"""Create client with credentials from central Settings service"""
from services.central_settings import get_resos_credentials
creds = await get_resos_credentials()
return cls(api_key=creds["api_key"] if creds else None)
async def __aenter__(self):
self.client = httpx.AsyncClient(timeout=30.0)