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:
parent
9057d764fa
commit
3650c92175
7 changed files with 51 additions and 282 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue