kitchen/backend/services/resos_api.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

183 lines
5.9 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 httpx
import base64
from datetime import date, datetime, timedelta
import logging
from typing import Optional
import asyncio
logger = logging.getLogger(__name__)
class ResosAPIError(Exception):
"""Custom exception for Resos API errors"""
pass
class ResosAPIClient:
"""
Async client for Resos API
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
"""
BASE_URL = "https://api.resos.com/v1"
def __init__(self, api_key: str):
self.api_key = api_key
# HTTP Basic Auth: base64_encode(api_key + ':')
self.auth_header = f"Basic {base64.b64encode(f'{api_key}:'.encode()).decode()}"
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 (GET request only)"""
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,
batch_days: int = 7
) -> list[dict]:
"""
Fetch bookings for date range with pagination and rate limiting (GET request only)
Uses batching by date spans to avoid hitting API limits.
Implements rate limiting (1 request per second).
Returns list of booking objects with structure:
{
'_id': 'booking_id',
'date': '2026-01-20',
'time': '19:00',
'people': 2,
'status': 'confirmed',
'guest': {...},
'customFields': [...],
'restaurantNotes': [...]
}
"""
all_bookings = []
current_date = from_date
# Batch requests by date spans (default 7 days per request)
while current_date <= to_date:
batch_end = min(current_date + timedelta(days=batch_days - 1), to_date)
from_datetime = f"{current_date}T00:00:00"
to_datetime = f"{batch_end}T23:59:59"
# Paginate through bookings for this date range
offset = 0
batch_total = 0
while True:
logger.info(f"Fetching Resos bookings: {current_date} to {batch_end} (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, # Max per request (Resos API limit)
"skip": offset # Pagination 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:
# No more bookings to fetch
break
all_bookings.extend(page_bookings)
batch_total += len(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
# Move to next page
offset += 100
# Rate limiting: 1 request per second
await asyncio.sleep(1)
logger.info(f"Total for {current_date} to {batch_end}: {batch_total} bookings")
# Move to next date batch
current_date = batch_end + timedelta(days=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 (GET request only)
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 (GET request only)
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()