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>
183 lines
5.9 KiB
Python
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()
|