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>
176 lines
5.4 KiB
Python
176 lines
5.4 KiB
Python
"""
|
|
Resos API Client
|
|
|
|
CRITICAL: This client is READ-ONLY. All methods use GET requests only.
|
|
NO data is written, modified, or deleted in Resos.
|
|
Data flows ONE WAY: Resos → Local Database
|
|
"""
|
|
import os
|
|
import httpx
|
|
import base64
|
|
import asyncio
|
|
import logging
|
|
from datetime import date, timedelta
|
|
from typing import List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ResosAPIError(Exception):
|
|
"""Custom exception for Resos API errors"""
|
|
pass
|
|
|
|
|
|
class ResosClient:
|
|
"""
|
|
Async client for Resos API
|
|
|
|
Rate limiting: ~60 requests/min, using 1s delay between requests
|
|
Pagination: Uses skip/limit, max 100 per request
|
|
Date filtering: Uses fromDateTime/toDateTime
|
|
"""
|
|
|
|
BASE_URL = "https://api.resos.com/v1"
|
|
|
|
def __init__(self, api_key: str = None):
|
|
# Use provided credentials or fall back to environment variables
|
|
self.api_key = api_key or os.getenv("RESOS_API_KEY")
|
|
if self.api_key:
|
|
# HTTP Basic Auth: base64_encode(api_key + ':')
|
|
self.auth_header = f"Basic {base64.b64encode(f'{self.api_key}:'.encode()).decode()}"
|
|
else:
|
|
self.auth_header = None
|
|
logger.warning("Resos API key not configured")
|
|
|
|
@classmethod
|
|
async def from_db(cls, db):
|
|
"""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)
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
await self.client.aclose()
|
|
|
|
async def test_connection(self) -> bool:
|
|
"""Test API connection by fetching opening hours"""
|
|
try:
|
|
response = await self.client.get(
|
|
f"{self.BASE_URL}/openingHours",
|
|
headers={"Authorization": self.auth_header}
|
|
)
|
|
return response.status_code == 200
|
|
except Exception as e:
|
|
logger.error(f"Resos connection test failed: {e}")
|
|
return False
|
|
|
|
async def get_bookings(
|
|
self,
|
|
from_date: date,
|
|
to_date: date
|
|
) -> List[dict]:
|
|
"""
|
|
Fetch bookings for date range with pagination and rate limiting.
|
|
|
|
Returns list of booking objects with structure:
|
|
{
|
|
'_id': 'booking_id',
|
|
'date': '2026-01-20',
|
|
'time': '19:00',
|
|
'people': 2,
|
|
'status': 'confirmed',
|
|
'source': 'website',
|
|
'guest': {...},
|
|
'customFields': [...],
|
|
'restaurantNotes': [...]
|
|
}
|
|
"""
|
|
all_bookings = []
|
|
offset = 0
|
|
|
|
from_datetime = f"{from_date}T00:00:00"
|
|
to_datetime = f"{to_date}T23:59:59"
|
|
|
|
while True:
|
|
logger.info(f"Fetching Resos bookings: {from_date} to {to_date} (offset: {offset})")
|
|
|
|
response = await self.client.get(
|
|
f"{self.BASE_URL}/bookings",
|
|
headers={"Authorization": self.auth_header},
|
|
params={
|
|
"fromDateTime": from_datetime,
|
|
"toDateTime": to_datetime,
|
|
"limit": 100,
|
|
"skip": offset
|
|
}
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
error_body = response.text
|
|
logger.error(f"Resos API error {response.status_code}: {error_body}")
|
|
raise ResosAPIError(f"Failed to fetch bookings: {response.status_code} - {error_body}")
|
|
|
|
data = response.json()
|
|
page_bookings = data if isinstance(data, list) else []
|
|
|
|
if not page_bookings:
|
|
break
|
|
|
|
all_bookings.extend(page_bookings)
|
|
logger.info(f"Fetched {len(page_bookings)} bookings (offset {offset})")
|
|
|
|
# If we got fewer than the limit, we've reached the end
|
|
if len(page_bookings) < 100:
|
|
break
|
|
|
|
offset += 100
|
|
|
|
# Rate limiting: 1 request per second
|
|
await asyncio.sleep(1)
|
|
|
|
logger.info(f"Total bookings fetched: {len(all_bookings)}")
|
|
return all_bookings
|
|
|
|
async def get_opening_hours(self) -> List[dict]:
|
|
"""
|
|
Fetch opening hours/service periods
|
|
|
|
Returns list of opening hour objects:
|
|
{
|
|
'_id': 'opening_hour_id',
|
|
'name': 'Dinner',
|
|
'startTime': '18:00',
|
|
'endTime': '22:00',
|
|
'days': ['monday', 'tuesday', 'wednesday', ...]
|
|
}
|
|
"""
|
|
response = await self.client.get(
|
|
f"{self.BASE_URL}/openingHours",
|
|
headers={"Authorization": self.auth_header},
|
|
params={"showDeleted": "false", "onlySpecial": "false"}
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
raise ResosAPIError(f"Failed to fetch opening hours: {response.status_code}")
|
|
|
|
return response.json()
|
|
|
|
async def get_custom_field_definitions(self) -> List[dict]:
|
|
"""
|
|
Fetch custom field definitions
|
|
|
|
Returns field definitions with choice options for dropdowns/radios
|
|
"""
|
|
response = await self.client.get(
|
|
f"{self.BASE_URL}/customFields",
|
|
headers={"Authorization": self.auth_header}
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
raise ResosAPIError(f"Failed to fetch custom fields: {response.status_code}")
|
|
|
|
return response.json()
|