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>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
183
backend/services/resos_api.py
Normal file
183
backend/services/resos_api.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue