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) 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): async def _test_resos(db: AsyncSession):
"""Test Resos API connection""" """Test Resos API connection"""
import httpx 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: if not api_key:
raise HTTPException(status_code=400, detail="Resos API key not configured") 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: def load_resos_credentials(db) -> dict:
"""Load Resos API credentials from database config.""" """Load Resos API credentials from central Settings service."""
import base64 from services.central_settings import get_resos_credentials_sync
creds = get_resos_credentials_sync()
def decrypt(value: str) -> str: return creds or {'api_key': None}
"""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}
def load_gl_config(db) -> tuple: 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 json
import logging import logging
import base64
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Optional, Tuple, List, Dict, Any from typing import Optional, Tuple, List, Dict, Any
@ -20,27 +19,6 @@ logger = logging.getLogger(__name__)
VALID_STATUSES = ('approved', 'arrived', 'seated', 'left') 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]]: def load_resos_custom_field_mappings(db) -> Dict[str, Dict[str, Any]]:
""" """
Load custom field mappings from resos_custom_field_mapping table. Load custom field mappings from resos_custom_field_mapping table.
@ -194,11 +172,13 @@ async def sync_resos_bookings_data(
) )
db.commit() db.commit()
# Load Resos API key # Load Resos API key from central Settings service
api_key = get_config_value(db, 'resos_api_key') 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: if not api_key:
raise Exception("Resos API key not configured") raise Exception("Resos API key not configured in central Settings")
# Load mappings # Load mappings
cf_mappings = load_resos_custom_field_mappings(db) 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]: def get_newbook_credentials_sync() -> Optional[dict]:
"""Blocking variant of get_newbook_credentials for sync job contexts.""" """Blocking variant of get_newbook_credentials for sync job contexts."""
return _extract_newbook(get_integration_sync("newbook")) 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 @classmethod
async def from_db(cls, db): async def from_db(cls, db):
"""Create client with credentials from database""" """Create client with credentials from central Settings service"""
from api.config import _get_config_value from services.central_settings import get_resos_credentials
creds = await get_resos_credentials()
api_key = await _get_config_value(db, "resos_api_key") return cls(api_key=creds["api_key"] if creds else None)
return cls(api_key=api_key)
async def __aenter__(self): async def __aenter__(self):
self.client = httpx.AsyncClient(timeout=30.0) self.client = httpx.AsyncClient(timeout=30.0)

View file

@ -361,3 +361,18 @@ tr:hover td { background: #f8fafc; }
padding: 16px; padding: 16px;
} }
} }
/* Sidebar scrollbar */
.nav-scroll::-webkit-scrollbar,
.sidebar::-webkit-scrollbar,
.sidebar-nav::-webkit-scrollbar { width: 4px; }
.nav-scroll::-webkit-scrollbar-track,
.sidebar::-webkit-scrollbar-track,
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.nav-scroll::-webkit-scrollbar-thumb,
.sidebar::-webkit-scrollbar-thumb,
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
.nav-scroll::-webkit-scrollbar-thumb:hover,
.sidebar::-webkit-scrollbar-thumb:hover,
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
.nav-scroll, .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }

View file

@ -2115,11 +2115,6 @@ const NewbookPage: React.FC = () => {
// RESOS SETTINGS PAGE // RESOS SETTINGS PAGE
// ============================================ // ============================================
interface ResosSettings {
resos_api_key: string | null
resos_api_key_set: boolean
}
interface ResosCustomField { interface ResosCustomField {
id: string id: string
name: string name: string
@ -2156,11 +2151,7 @@ const ResosPage: React.FC = () => {
return ( return (
<div style={styles.section}> <div style={styles.section}>
<h2 style={styles.sectionTitle}>Resos Settings</h2> <h2 style={styles.sectionTitle}>Resos Settings</h2>
<p style={styles.hint}>Configure your Resos API connection and sync settings for restaurant reservation management.</p> <p style={styles.hint}>Configure Resos sync settings for restaurant reservation management. The Resos API key is managed centrally in the <strong>Settings</strong> app.</p>
<ResosAPIConfigSection />
<div style={styles.divider} />
<ResosCustomFieldMappingSection /> <ResosCustomFieldMappingSection />
@ -2183,149 +2174,6 @@ const ResosPage: React.FC = () => {
) )
} }
// ============================================
// RESOS API CONFIGURATION SECTION
// ============================================
const ResosAPIConfigSection: React.FC = () => {
const [apiKey, setApiKey] = useState('')
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle')
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
const [testMessage, setTestMessage] = useState('')
const { data: settings, isLoading } = useQuery({
queryKey: ['resos-settings'],
queryFn: async () => {
const response = await fetch('/forecasting/api/config/settings/resos')
if (!response.ok) throw new Error('Failed to fetch settings')
return response.json() as Promise<ResosSettings>
},
staleTime: 30000,
})
const handleSave = async () => {
setSaveStatus('saving')
try {
const response = await fetch('/forecasting/api/config/settings/resos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
resos_api_key: apiKey || undefined,
})
})
if (!response.ok) throw new Error('Failed to save')
setSaveStatus('success')
setApiKey('')
setTimeout(() => setSaveStatus('idle'), 3000)
} catch {
setSaveStatus('error')
setTimeout(() => setSaveStatus('idle'), 3000)
}
}
const handleTestConnection = async () => {
setTestStatus('testing')
setTestMessage('')
try {
const response = await fetch('/forecasting/api/config/settings/resos/test', {
method: 'POST',
})
const data = await response.json()
if (response.ok) {
setTestStatus('success')
setTestMessage(data.message || 'Connection successful!')
} else {
setTestStatus('error')
setTestMessage(data.detail || 'Connection failed')
}
} catch {
setTestStatus('error')
setTestMessage('Connection failed')
}
setTimeout(() => {
setTestStatus('idle')
setTestMessage('')
}, 5000)
}
if (isLoading) {
return <div style={styles.loading}>Loading settings...</div>
}
return (
<div style={styles.apiConfigRow}>
<div style={styles.apiConfigLeft}>
<h3 style={styles.subsectionTitle}>API Configuration</h3>
<div style={styles.form}>
<label style={styles.label}>
<span>API Key</span>
<div style={styles.inputWithStatus}>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={settings?.resos_api_key_set ? '••••••••' : 'Enter API key'}
style={styles.input}
/>
{settings?.resos_api_key_set && (
<span style={styles.keyStatus}>Key configured</span>
)}
</div>
</label>
<div style={styles.buttonRow}>
<button
onClick={handleSave}
disabled={saveStatus === 'saving'}
style={mergeStyles(
buttonStyle('primary'),
saveStatus === 'success' ? { background: colors.success } : {},
saveStatus === 'error' ? { background: colors.error } : {}
)}
>
{saveStatus === 'saving' ? 'Saving...' :
saveStatus === 'success' ? 'Saved!' :
saveStatus === 'error' ? 'Error' : 'Save Settings'}
</button>
<button
onClick={handleTestConnection}
disabled={testStatus === 'testing'}
style={buttonStyle('outline')}
>
{testStatus === 'testing' ? 'Testing...' : 'Test Connection'}
</button>
</div>
{testMessage && (
<div style={{
...styles.statusMessage,
background: testStatus === 'success' ? colors.successBg : colors.errorBg,
color: testStatus === 'success' ? colors.success : colors.error,
}}>
{testMessage}
</div>
)}
</div>
</div>
<div style={styles.apiConfigRight}>
<h3 style={styles.subsectionTitle}>Connection Status</h3>
<div style={styles.statusGridVertical}>
<div style={styles.statusItem}>
<span style={styles.statusLabel}>API Key</span>
<span style={settings?.resos_api_key_set ? styles.statusOk : styles.statusPending}>
{settings?.resos_api_key_set ? 'Configured' : 'Not set'}
</span>
</div>
</div>
</div>
</div>
)
}
// ============================================ // ============================================
// RESOS CUSTOM FIELD MAPPING SECTION // RESOS CUSTOM FIELD MAPPING SECTION
// ============================================ // ============================================