kitchen/backend/api/sambapos.py
jtricerolph d7898ba897 Add 'use main stack settings' toggle for third-party integration credentials
Each of Newbook, Resos, SambaPOS, SMTP, and Nextcloud now has a checkbox at the
top of its credentials block. When enabled, the app reads auth credentials from
the central stack settings service (SETTINGS_URL + STACK_INTERNAL_SECRET) and
the local auth fields are grayed out. App-specific fields (base path, GL codes,
keywords, sync intervals, etc.) remain editable regardless.

Backend: new use_global_* columns on kitchen_settings, migration, global_settings_service
with apply_global_overrides() for in-memory credential injection, GET /api/settings/global-status
endpoint, and apply_global_overrides() called in test-connection endpoints and FileArchivalService.

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

697 lines
23 KiB
Python

"""
SambaPOS API Endpoints
Handles SambaPOS MSSQL configuration and category management.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from database import get_db
from models.user import User
from models.settings import KitchenSettings
from auth import get_current_user, require_cap
from services.sambapos_api import SambaPOSClient
router = APIRouter()
# ============ Pydantic Schemas ============
class SambaPOSSettingsResponse(BaseModel):
sambapos_db_host: str | None
sambapos_db_port: int | None
sambapos_db_name: str | None
sambapos_db_username: str | None
sambapos_db_password_set: bool
sambapos_tracked_categories: list[str]
sambapos_excluded_items: list[str]
use_global_sambapos: bool = False
class Config:
from_attributes = True
class SambaPOSSettingsUpdate(BaseModel):
sambapos_db_host: str | None = None
sambapos_db_port: int | None = None
sambapos_db_name: str | None = None
sambapos_db_username: str | None = None
sambapos_db_password: str | None = None
use_global_sambapos: bool | None = None
class CategoryResponse(BaseModel):
id: int
name: str
class MenuItemResponse(BaseModel):
name: str
category: str
class TrackedCategoriesUpdate(BaseModel):
categories: list[str]
class ExcludedItemsUpdate(BaseModel):
items: list[str]
# ============ Settings Endpoints ============
@router.get("/settings", response_model=SambaPOSSettingsResponse)
async def get_sambapos_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get SambaPOS connection settings"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Parse tracked categories from comma-separated string
tracked_categories = []
if settings.sambapos_tracked_categories:
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
# Parse excluded items from comma-separated string
excluded_items = []
if settings.sambapos_excluded_items:
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
return SambaPOSSettingsResponse(
sambapos_db_host=settings.sambapos_db_host,
sambapos_db_port=settings.sambapos_db_port,
sambapos_db_name=settings.sambapos_db_name,
sambapos_db_username=settings.sambapos_db_username,
sambapos_db_password_set=bool(settings.sambapos_db_password),
sambapos_tracked_categories=tracked_categories,
sambapos_excluded_items=excluded_items,
use_global_sambapos=settings.use_global_sambapos or False,
)
@router.patch("/settings", response_model=SambaPOSSettingsResponse)
async def update_sambapos_settings(
update: SambaPOSSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update SambaPOS connection settings"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Update fields
update_data = update.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None:
setattr(settings, field, value)
await db.commit()
await db.refresh(settings)
# Parse tracked categories from comma-separated string
tracked_categories = []
if settings.sambapos_tracked_categories:
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
# Parse excluded items from pipe-separated string
excluded_items = []
if settings.sambapos_excluded_items:
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
return SambaPOSSettingsResponse(
sambapos_db_host=settings.sambapos_db_host,
sambapos_db_port=settings.sambapos_db_port,
sambapos_db_name=settings.sambapos_db_name,
sambapos_db_username=settings.sambapos_db_username,
sambapos_db_password_set=bool(settings.sambapos_db_password),
sambapos_tracked_categories=tracked_categories,
sambapos_excluded_items=excluded_items,
use_global_sambapos=settings.use_global_sambapos or False,
)
@router.post("/test-connection")
async def test_sambapos_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Test SambaPOS database connection"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
from services.global_settings_service import apply_global_overrides
await apply_global_overrides(settings)
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not fully configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
result = await client.test_connection()
if result["success"]:
return {"status": "success", "message": "SambaPOS connection successful"}
else:
raise HTTPException(status_code=400, detail=f"Connection failed: {result['message']}")
# ============ Categories Endpoints ============
@router.get("/categories", response_model=list[CategoryResponse])
async def get_sambapos_categories(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch all menu categories from SambaPOS database"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
categories = await client.get_categories()
return [CategoryResponse(id=cat["id"], name=cat["name"]) for cat in categories]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to fetch categories: {str(e)}")
@router.get("/tracked-categories")
async def get_tracked_categories(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get list of category names enabled for top sellers"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Parse tracked categories from comma-separated string
tracked_categories = []
if settings.sambapos_tracked_categories:
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
return {"categories": tracked_categories}
@router.patch("/tracked-categories")
async def update_tracked_categories(
update: TrackedCategoriesUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update which categories are included in top sellers"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Store as comma-separated string
settings.sambapos_tracked_categories = ','.join(update.categories)
await db.commit()
return {"status": "success", "categories": update.categories}
@router.get("/debug/menuitems")
async def debug_menuitems(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Debug endpoint to explore MenuItems table structure"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings or not settings.sambapos_db_password:
raise HTTPException(status_code=400, detail="SambaPOS not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
return await client.debug_menu_items()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# ============ Menu Items Endpoints ============
@router.get("/menu-items", response_model=list[MenuItemResponse])
async def get_menu_items(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch all unique menu item names with their categories for exclusion selection"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
# Get tracked categories to filter menu items
tracked_categories = []
if settings.sambapos_tracked_categories:
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
items = await client.get_menu_item_names(categories=tracked_categories if tracked_categories else None)
return [MenuItemResponse(name=item["name"], category=item["category"]) for item in items]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to fetch menu items: {str(e)}")
class MenuItemWithPortionResponse(BaseModel):
menu_item_name: str
portion_name: str
category: str
on_pos_menu: bool
@router.get("/menu-items-with-portions", response_model=list[MenuItemWithPortionResponse])
async def get_menu_items_with_portions(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch all menu items with their portion names, grouped by Kitchen Course category."""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
# Get tracked categories to filter
tracked_categories = []
if settings.sambapos_tracked_categories:
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
items = await client.get_menu_items_with_portions(
categories=tracked_categories if tracked_categories else None
)
return [
MenuItemWithPortionResponse(
menu_item_name=item["menu_item_name"],
portion_name=item["portion_name"],
category=item["category"],
on_pos_menu=item.get("on_pos_menu", False)
)
for item in items
]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to fetch menu items with portions: {str(e)}")
@router.get("/excluded-items")
async def get_excluded_items(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get list of menu item names excluded from top sellers report"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Parse excluded items from pipe-separated string
excluded_items = []
if settings.sambapos_excluded_items:
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
return {"items": excluded_items}
@router.patch("/excluded-items")
async def update_excluded_items(
update: ExcludedItemsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update which menu item GroupCodes are excluded from top sellers report"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Store as pipe-separated string (to allow commas in names)
settings.sambapos_excluded_items = '|'.join(update.items)
await db.commit()
return {"status": "success", "items": update.items}
# ============ Group Codes Endpoints ============
class GroupCodeResponse(BaseModel):
name: str
@router.get("/group-codes", response_model=list[GroupCodeResponse])
async def get_group_codes(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch all distinct GroupCode values from MenuItems table for exclusion selection"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
group_codes = await client.get_menu_group_codes()
return [GroupCodeResponse(name=gc["name"]) for gc in group_codes]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to fetch group codes: {str(e)}")
# ============ GL Codes Endpoints (Phase 8) ============
class GLCodeResponse(BaseModel):
code: str
class GLCodesUpdate(BaseModel):
food_codes: list[str]
beverage_codes: list[str]
@router.get("/gl-codes", response_model=list[GLCodeResponse])
async def get_gl_codes(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch all unique GL codes from ProductTag custom tags for food/beverage classification"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
gl_codes = await client.get_gl_codes()
return [GLCodeResponse(code=gc["code"]) for gc in gl_codes]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to fetch GL codes: {str(e)}")
@router.get("/gl-codes/selected")
async def get_selected_gl_codes(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get currently selected food and beverage GL codes"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Parse food GL codes from comma-separated string
food_codes = []
if settings.sambapos_food_gl_codes:
food_codes = [c.strip() for c in settings.sambapos_food_gl_codes.split(',') if c.strip()]
# Parse beverage GL codes from comma-separated string
beverage_codes = []
if settings.sambapos_beverage_gl_codes:
beverage_codes = [c.strip() for c in settings.sambapos_beverage_gl_codes.split(',') if c.strip()]
return {
"food_codes": food_codes,
"beverage_codes": beverage_codes
}
@router.patch("/gl-codes")
async def update_gl_codes(
update: GLCodesUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update food and beverage GL code selections"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Store as comma-separated strings
settings.sambapos_food_gl_codes = ','.join(update.food_codes)
settings.sambapos_beverage_gl_codes = ','.join(update.beverage_codes)
await db.commit()
return {
"status": "success",
"food_codes": update.food_codes,
"beverage_codes": update.beverage_codes
}
@router.get("/debug/custom-tags")
async def debug_custom_tags(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Debug endpoint to see sample CustomTags from MenuItems"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
return await client.debug_menu_items()
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to debug custom tags: {str(e)}")
@router.get("/debug/zero-price-order-states")
async def debug_zero_price_order_states(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Debug endpoint: returns sample OrderStates blobs for zero-priced orders.
Use this to discover the JSON structure and identify where original/package price is stored.
"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings or not settings.sambapos_db_password:
raise HTTPException(status_code=400, detail="SambaPOS not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
return await client.debug_zero_price_order_states()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/debug/table-schema")
async def debug_table_schema(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Debug endpoint to inspect column names in SambaPOS tables"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
return await client.debug_table_schema()
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to debug schema: {str(e)}")