kitchen/backend/models/settings.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

245 lines
16 KiB
Python

from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, DateTime, ForeignKey, Text, Boolean, Numeric, Integer
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class KitchenSettings(Base):
"""Kitchen-level settings including OCR and Newbook configuration"""
__tablename__ = "kitchen_settings"
id: Mapped[int] = mapped_column(primary_key=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), unique=True)
# Azure Document Intelligence settings
azure_endpoint: Mapped[str | None] = mapped_column(String(500), nullable=True)
azure_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
# OCR post-processing options
ocr_clean_product_codes: Mapped[bool] = mapped_column(Boolean, default=False) # Strip section headers from product codes
ocr_filter_subtotal_rows: Mapped[bool] = mapped_column(Boolean, default=False) # Filter subtotal/total rows from line items
ocr_use_weight_as_quantity: Mapped[bool] = mapped_column(Boolean, default=False) # For KG items, use weight as quantity when it matches total
# Newbook API settings
newbook_api_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
newbook_api_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
newbook_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
newbook_api_region: Mapped[str | None] = mapped_column(String(10), nullable=True) # au, ap, eu, us
newbook_instance_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Newbook sync configuration
newbook_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
newbook_auto_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# Separate sync interval for next 7 days (in minutes, default 15)
newbook_upcoming_sync_interval: Mapped[int] = mapped_column(Integer, default=15)
newbook_upcoming_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
newbook_last_upcoming_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Newbook allocation GL mapping (CSV-style, e.g. "4100,4101,4102")
newbook_breakfast_gl_codes: Mapped[str | None] = mapped_column(String(500), nullable=True)
newbook_dinner_gl_codes: Mapped[str | None] = mapped_column(String(500), nullable=True)
# VAT rates for calculating net from gross (e.g., 0.10 for 10% VAT)
newbook_breakfast_vat_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True, default=Decimal("0.10"))
newbook_dinner_vat_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True, default=Decimal("0.10"))
# Resos API Configuration
resos_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
resos_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
resos_auto_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# Separate sync interval for next 7 days (in minutes, default 15)
resos_upcoming_sync_interval: Mapped[int] = mapped_column(Integer, default=15)
resos_upcoming_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
resos_last_upcoming_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Resos Flagging Configuration
resos_large_group_threshold: Mapped[int] = mapped_column(Integer, default=8)
resos_note_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) # Pipe-separated: "birthday|anniversary|proposal"
resos_allergy_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) # Pipe-separated: "gluten|dairy|nut|shellfish"
# Resos Custom Field & Period Mapping
resos_custom_field_mapping: Mapped[dict | None] = mapped_column(JSONB, nullable=True) # Format: {"booking_number": "field_id_123", ...}
resos_opening_hours_mapping: Mapped[list | None] = mapped_column(JSONB, nullable=True) # Format: [{"resos_id": "abc123", "display_name": "Lunch", "actual_end": "14:30"}, ...]
# Resos SambaPOS Integration
resos_restaurant_table_entities: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated entity names
# Manual Breakfast Configuration (not in Resos)
resos_enable_manual_breakfast: Mapped[bool] = mapped_column(Boolean, default=False)
# Format: [{"day": 1, "start": "07:00", "end": "11:00"}, ...] where day: 1=Monday, 7=Sunday
resos_manual_breakfast_periods: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Resos Flag Icon Mapping (customizable icons for each flag type)
# Format: {"allergies": "🦀", "large_group": "⚠️", "birthday": "🎂", "anniversary": "💍", ...}
resos_flag_icon_mapping: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Resos Arrival Widget Service Filter (filter arrivals widget by service type from mapping)
resos_arrival_widget_service_filter: Mapped[str | None] = mapped_column(String(50), nullable=True) # service_type: breakfast/lunch/dinner/other
# SambaPOS MSSQL Connection
sambapos_db_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
sambapos_db_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=1433)
sambapos_db_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
sambapos_db_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
sambapos_db_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
# SambaPOS tracked categories (comma-separated list, order preserved for display)
sambapos_tracked_categories: Mapped[str | None] = mapped_column(String(1000), nullable=True)
# SambaPOS excluded menu items (comma-separated list of menu item names to exclude from reports)
sambapos_excluded_items: Mapped[str | None] = mapped_column(Text, nullable=True)
# Phase 8.1: GL code configuration for food/beverage split
sambapos_food_gl_codes: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated GL codes for food items
sambapos_beverage_gl_codes: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated GL codes for beverage items
# SMTP email configuration
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=587)
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
smtp_from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_from_name: Mapped[str | None] = mapped_column(String(255), nullable=True, default="Kitchen Invoice System")
# Support request email (where screenshot reports are sent)
support_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Dext integration
dext_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
dext_include_notes: Mapped[bool] = mapped_column(Boolean, default=True)
dext_include_non_stock: Mapped[bool] = mapped_column(Boolean, default=True)
dext_auto_send_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
dext_manual_send_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
dext_include_annotations: Mapped[bool] = mapped_column(Boolean, default=True) # Include PDF annotations when sending to Dext
# Nextcloud settings
nextcloud_host: Mapped[str | None] = mapped_column(String(500), nullable=True)
nextcloud_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
nextcloud_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
nextcloud_base_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/Kitchen Invoices")
nextcloud_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
nextcloud_delete_local: Mapped[bool] = mapped_column(Boolean, default=False) # Delete local file after successful archive
# Backup settings
backup_frequency: Mapped[str | None] = mapped_column(String(20), nullable=True, default="manual") # daily, weekly, manual
backup_retention_count: Mapped[int] = mapped_column(Integer, default=7)
backup_destination: Mapped[str | None] = mapped_column(String(20), nullable=True, default="local") # local, nextcloud, smb
backup_time: Mapped[str | None] = mapped_column(String(5), nullable=True, default="03:00")
# Nextcloud backup path (when backup_destination = "nextcloud")
backup_nextcloud_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/Backups")
# SMB backup settings (used when backup_destination = "smb")
backup_smb_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
backup_smb_share: Mapped[str | None] = mapped_column(String(255), nullable=True)
backup_smb_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
backup_smb_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
backup_smb_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/backups")
# Last backup tracking
backup_last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
backup_last_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
backup_last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
# IMAP Email Inbox settings
imap_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
imap_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=993)
imap_use_ssl: Mapped[bool] = mapped_column(Boolean, default=True)
imap_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
imap_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
imap_folder: Mapped[str | None] = mapped_column(String(255), nullable=True, default="INBOX")
imap_poll_interval: Mapped[int] = mapped_column(Integer, default=15) # minutes
imap_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
imap_confidence_threshold: Mapped[Decimal | None] = mapped_column(Numeric(3, 2), nullable=True, default=Decimal("0.50"))
imap_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# General settings
currency_symbol: Mapped[str] = mapped_column(String(5), default="£")
date_format: Mapped[str] = mapped_column(String(20), default="DD/MM/YYYY")
# Warning thresholds
high_quantity_threshold: Mapped[int] = mapped_column(default=100) # Warn if qty > this value
# PDF annotation settings
pdf_annotations_enabled: Mapped[bool] = mapped_column(Boolean, default=True) # Enable adding annotations to PDFs
pdf_preview_show_annotations: Mapped[bool] = mapped_column(Boolean, default=True) # Show annotations in preview window
# Price change detection settings
price_change_lookback_days: Mapped[int] = mapped_column(Integer, default=30) # Days to look back for price comparison
price_change_amber_threshold: Mapped[int] = mapped_column(Integer, default=10) # % change for amber warning
price_change_red_threshold: Mapped[int] = mapped_column(Integer, default=20) # % change for red alert
# Admin-only page restrictions (comma-separated list of page paths, e.g., "/settings,/suppliers")
admin_restricted_pages: Mapped[str | None] = mapped_column(Text, nullable=True)
# Forecast API integration (Spend Budget feature)
forecast_api_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
forecast_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Budget settings
budget_gp_target: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True, default=Decimal("65.00"))
budget_lookback_weeks: Mapped[int] = mapped_column(Integer, default=4)
# KDS (Kitchen Display System) settings
kds_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
kds_graphql_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
kds_graphql_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
kds_graphql_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
kds_graphql_client_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
kds_poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=6000)
kds_timer_green_seconds: Mapped[int] = mapped_column(Integer, default=300)
kds_timer_amber_seconds: Mapped[int] = mapped_column(Integer, default=600)
kds_timer_red_seconds: Mapped[int] = mapped_column(Integer, default=900)
kds_course_order: Mapped[list | None] = mapped_column(JSONB, nullable=True, default=["Starters", "Mains", "Desserts"])
kds_show_completed_for_seconds: Mapped[int] = mapped_column(Integer, default=30)
# Away timer thresholds (time since food sent to table - "eating" phase)
kds_away_timer_green_seconds: Mapped[int] = mapped_column(Integer, default=600) # 10 minutes
kds_away_timer_amber_seconds: Mapped[int] = mapped_column(Integer, default=900) # 15 minutes
kds_away_timer_red_seconds: Mapped[int] = mapped_column(Integer, default=1200) # 20 minutes
kds_bookings_refresh_seconds: Mapped[int] = mapped_column(Integer, default=60)
# Cost distribution settings
cost_distribution_max_days: Mapped[int] = mapped_column(Integer, default=90)
# LLM integration (Claude) — see LLM-MANIFEST.md for removal instructions
llm_enabled: Mapped[bool] = mapped_column(Boolean, default=False) # Master kill switch — False = zero AI footprint
anthropic_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
llm_model: Mapped[str | None] = mapped_column(String(100), nullable=True, default="claude-haiku-4-5-20251001")
llm_confidence_threshold: Mapped[Decimal | None] = mapped_column(Numeric(3, 2), nullable=True, default=Decimal("0.80"))
llm_monthly_token_limit: Mapped[int] = mapped_column(Integer, default=500000) # ~$1.25/month on Haiku
llm_features_enabled: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default={
"label_parsing": True, "invoice_assist": True, "ingredient_match": True,
"recipe_scanning": True, "line_item_reconciliation": True, "menu_description": True,
"dispute_email": True, "duplicate_detection": True, "supplier_alias": True, "yield_estimation": True
})
# Global settings flags — use central stack settings auth credentials for each integration
use_global_smtp: Mapped[bool] = mapped_column(Boolean, default=False)
use_global_nextcloud: Mapped[bool] = mapped_column(Boolean, default=False)
use_global_newbook: Mapped[bool] = mapped_column(Boolean, default=False)
use_global_resos: Mapped[bool] = mapped_column(Boolean, default=False)
use_global_sambapos: Mapped[bool] = mapped_column(Boolean, default=False)
# Internal API key (for in-house apps like menu display plugin)
api_key: Mapped[str | None] = mapped_column(String(100), nullable=True)
api_key_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# Kitchen details (for PO letterhead)
kitchen_display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
kitchen_address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True)
kitchen_address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
kitchen_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
kitchen_postcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
kitchen_phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
kitchen_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="settings")
# Forward reference
from .user import Kitchen