diff --git a/models.py b/models.py new file mode 100644 index 0000000..b842a5a --- /dev/null +++ b/models.py @@ -0,0 +1,491 @@ +""" +Pydantic v1 models for the restaurant extension. + +Naming convention: + * `` — the row as stored / returned (id + timestamps). + * `Create` — request body for POST. + * `Update` — request body for PUT/PATCH (all fields optional). + +JSON-encoded list/dict columns are parsed in pre-validators so callers +always see structured types. +""" + +import json +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field, validator + + +# --------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------- # + + +def _parse_json_list(v: Any) -> list: + if v is None or v == "": + return [] + if isinstance(v, str): + try: + return json.loads(v) or [] + except json.JSONDecodeError: + return [] + return list(v) + + +def _parse_json_dict(v: Any) -> dict: + if v is None or v == "": + return {} + if isinstance(v, str): + try: + return json.loads(v) or {} + except json.JSONDecodeError: + return {} + return dict(v) + + +# --------------------------------------------------------------------- # +# Restaurant # +# --------------------------------------------------------------------- # + + +class OpenHours(BaseModel): + """Weekly opening schedule. Weekday key 0=Mon .. 6=Sun. + + Each day is a list of {start, end} ranges so a venue can be open + e.g. 11:00-15:00 and 18:00-23:00 in the same day. Persisted as + JSON in the DB. + + Typed as plain `dict` (not `dict[str, list[dict[str, str]]]`) so + LNbits's `db.dict_to_model` walks the field cleanly: its + introspection calls `issubclass(field.type_, bool)` while + iterating, and a parameterized generic alias trips it with + "issubclass() arg 1 must be a class". The runtime shape is + still the dict-of-lists-of-dicts described above. + """ + + schedule: dict = Field(default_factory=dict) + + +class SocialLinks(BaseModel): + website: Optional[str] = None + instagram: Optional[str] = None + facebook: Optional[str] = None + twitter: Optional[str] = None + nostr: Optional[str] = None + + +class RestaurantExtra(BaseModel): + notes: Optional[str] = None + # Typed as plain `dict` (not `dict[str, str]`) so LNbits's + # `db.dict_to_model` round-trips it cleanly — see OpenHours for + # the same workaround and rationale. + fields: dict = Field(default_factory=dict) + + +class CreateRestaurant(BaseModel): + wallet: Optional[str] = None + name: str + slug: str + description: Optional[str] = None + currency: str = "sat" + timezone: str = "UTC" + location: Optional[str] = None + geohash: Optional[str] = None + logo_url: Optional[str] = None + banner_url: Optional[str] = None + social_links: SocialLinks = Field(default_factory=SocialLinks) + open_hours: OpenHours = Field(default_factory=OpenHours) + is_open: bool = True + accepts_cash: bool = True + accepts_lightning: bool = True + tip_presets: list[int] = Field(default_factory=list) + tax_rate: float = 0 + printer_endpoint: Optional[str] = None + nostr_pubkey: Optional[str] = None + nostr_relays: list[str] = Field(default_factory=list) + extra: RestaurantExtra = Field(default_factory=RestaurantExtra) + + +class Restaurant(BaseModel): + id: str + wallet: str + name: str + slug: str + description: Optional[str] = None + currency: str = "sat" + timezone: str = "UTC" + location: Optional[str] = None + geohash: Optional[str] = None + logo_url: Optional[str] = None + banner_url: Optional[str] = None + social_links: SocialLinks = Field(default_factory=SocialLinks) + open_hours: OpenHours = Field(default_factory=OpenHours) + is_open: bool = True + accepts_cash: bool = True + accepts_lightning: bool = True + tip_presets: list[int] = Field(default_factory=list) + tax_rate: float = 0 + printer_endpoint: Optional[str] = None + nostr_pubkey: Optional[str] = None + nostr_relays: list[str] = Field(default_factory=list) + nostr_event_id: Optional[str] = None + nostr_event_created_at: Optional[int] = None + extra: RestaurantExtra = Field(default_factory=RestaurantExtra) + time: datetime + + @validator("social_links", pre=True) + def _parse_social(cls, v): + if isinstance(v, str): + return SocialLinks(**_parse_json_dict(v)) + return v or SocialLinks() + + @validator("open_hours", pre=True) + def _parse_hours(cls, v): + if isinstance(v, str): + return OpenHours(**_parse_json_dict(v)) + return v or OpenHours() + + @validator("tip_presets", pre=True) + def _parse_presets(cls, v): + return _parse_json_list(v) + + @validator("nostr_relays", pre=True) + def _parse_relays(cls, v): + return _parse_json_list(v) + + @validator("extra", pre=True) + def _parse_extra(cls, v): + if isinstance(v, str): + return RestaurantExtra(**_parse_json_dict(v)) + return v or RestaurantExtra() + + +# --------------------------------------------------------------------- # +# Categories / subcategories # +# --------------------------------------------------------------------- # + + +class CreateCategory(BaseModel): + restaurant_id: str + name: str + description: Optional[str] = None + sort_order: int = 0 + image_url: Optional[str] = None + + +class Category(BaseModel): + id: str + restaurant_id: str + name: str + description: Optional[str] = None + sort_order: int = 0 + image_url: Optional[str] = None + time: datetime + + +class CreateSubcategory(BaseModel): + category_id: str + name: str + sort_order: int = 0 + + +class Subcategory(BaseModel): + id: str + category_id: str + name: str + sort_order: int = 0 + time: datetime + + +# --------------------------------------------------------------------- # +# Menu items # +# --------------------------------------------------------------------- # + + +class MenuItemExtra(BaseModel): + """Free-form metadata that doesn't deserve a column yet.""" + + notes: Optional[str] = None + # Plain `dict` — see OpenHours for the LNbits round-trip workaround. + fields: dict = Field(default_factory=dict) + + +class CreateMenuItem(BaseModel): + restaurant_id: str + category_id: Optional[str] = None + subcategory_id: Optional[str] = None + name: str + description: Optional[str] = None + price: float = 0 + currency: str = "sat" + sku: Optional[str] = None + images: list[str] = Field(default_factory=list) + dietary: list[str] = Field(default_factory=list) + allergens: list[str] = Field(default_factory=list) + ingredients: list[str] = Field(default_factory=list) + calories: Optional[int] = None + sort_order: int = 0 + is_available: bool = True + is_featured: bool = False + stock: Optional[int] = None + low_stock_threshold: Optional[int] = None + extra: MenuItemExtra = Field(default_factory=MenuItemExtra) + + +class MenuItem(BaseModel): + id: str + restaurant_id: str + category_id: Optional[str] = None + subcategory_id: Optional[str] = None + name: str + description: Optional[str] = None + price: float = 0 + currency: str = "sat" + sku: Optional[str] = None + images: list[str] = Field(default_factory=list) + dietary: list[str] = Field(default_factory=list) + allergens: list[str] = Field(default_factory=list) + ingredients: list[str] = Field(default_factory=list) + calories: Optional[int] = None + sort_order: int = 0 + is_available: bool = True + is_featured: bool = False + stock: Optional[int] = None + low_stock_threshold: Optional[int] = None + nostr_event_id: Optional[str] = None + nostr_event_created_at: Optional[int] = None + extra: MenuItemExtra = Field(default_factory=MenuItemExtra) + time: datetime + + @validator("images", "dietary", "allergens", "ingredients", pre=True) + def _parse_lists(cls, v): + return _parse_json_list(v) + + @validator("extra", pre=True) + def _parse_extra(cls, v): + if isinstance(v, str): + return MenuItemExtra(**_parse_json_dict(v)) + return v or MenuItemExtra() + + +# --------------------------------------------------------------------- # +# Modifier groups + modifiers # +# --------------------------------------------------------------------- # + + +class CreateModifierGroup(BaseModel): + menu_item_id: str + name: str + kind: str = "required" # 'required' | 'optional' + selection: str = "one" # 'one' | 'many' + min_selections: int = 0 + max_selections: Optional[int] = None + sort_order: int = 0 + + +class ModifierGroup(BaseModel): + id: str + menu_item_id: str + name: str + kind: str = "required" + selection: str = "one" + min_selections: int = 0 + max_selections: Optional[int] = None + sort_order: int = 0 + time: datetime + + +class CreateModifier(BaseModel): + group_id: str + name: str + description: Optional[str] = None + price_delta: float = 0 + is_default: bool = False + sort_order: int = 0 + + +class Modifier(BaseModel): + id: str + group_id: str + name: str + description: Optional[str] = None + price_delta: float = 0 + is_default: bool = False + sort_order: int = 0 + time: datetime + + +# --------------------------------------------------------------------- # +# Availability windows # +# --------------------------------------------------------------------- # + + +class CreateAvailabilityWindow(BaseModel): + menu_item_id: str + weekday: Optional[int] = None # 0=Mon, 6=Sun, None = every day + start_time: str # 'HH:MM' + end_time: str # 'HH:MM' + + @validator("weekday") + def _check_weekday(cls, v): + if v is not None and not 0 <= v <= 6: + raise ValueError("weekday must be in 0..6 or null") + return v + + +class AvailabilityWindow(BaseModel): + id: str + menu_item_id: str + weekday: Optional[int] = None + start_time: str + end_time: str + time: datetime + + +# --------------------------------------------------------------------- # +# Orders # +# --------------------------------------------------------------------- # + + +class SelectedModifier(BaseModel): + """Snapshot of a chosen modifier at order time.""" + + group_id: Optional[str] = None + group_name: Optional[str] = None + modifier_id: Optional[str] = None + name: str + price_delta: float = 0 + + +class CreateOrderItem(BaseModel): + menu_item_id: str + quantity: int = 1 + selected_modifiers: list[SelectedModifier] = Field(default_factory=list) + note: Optional[str] = None + + +class OrderItemRow(BaseModel): + id: str + order_id: str + menu_item_id: Optional[str] = None + name: str + quantity: int = 1 + unit_price_msat: int = 0 + line_total_msat: int = 0 + selected_modifiers: list[SelectedModifier] = Field(default_factory=list) + note: Optional[str] = None + time: datetime + + @validator("selected_modifiers", pre=True) + def _parse_mods(cls, v): + if isinstance(v, str): + try: + raw = json.loads(v) if v else [] + return [SelectedModifier(**m) for m in raw] + except json.JSONDecodeError: + return [] + return v or [] + + +class OrderExtra(BaseModel): + fiat: bool = False + fiat_currency: Optional[str] = None + fiat_rate: Optional[float] = None + refund_address: Optional[str] = None + # Plain `dict` — see OpenHours for the LNbits round-trip workaround. + fields: dict = Field(default_factory=dict) + + +class CreateOrder(BaseModel): + restaurant_id: str + customer_pubkey: Optional[str] = None + customer_name: Optional[str] = None + customer_contact: Optional[str] = None + items: list[CreateOrderItem] + tip_msat: int = 0 + note: Optional[str] = None + parent_order_ref: Optional[str] = None + channel: str = "rest" # 'rest' | 'nostr' | 'kiosk' | 'pos' + payment_method: str = "lightning" # 'lightning' | 'cash' | 'internal' + extra: OrderExtra = Field(default_factory=OrderExtra) + + +class Order(BaseModel): + id: str + restaurant_id: str + wallet: str + customer_pubkey: Optional[str] = None + customer_name: Optional[str] = None + customer_contact: Optional[str] = None + status: str = "pending" + channel: str = "rest" + payment_method: str = "lightning" + payment_hash: Optional[str] = None + bolt11: Optional[str] = None + subtotal_msat: int = 0 + tip_msat: int = 0 + tax_msat: int = 0 + total_msat: int = 0 + currency_display: str = "sat" + fiat_amount: Optional[float] = None + fiat_rate: Optional[float] = None + note: Optional[str] = None + parent_order_ref: Optional[str] = None + paid_at: Optional[datetime] = None + accepted_at: Optional[datetime] = None + ready_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + canceled_at: Optional[datetime] = None + extra: OrderExtra = Field(default_factory=OrderExtra) + time: datetime + + @validator("extra", pre=True) + def _parse_extra(cls, v): + if isinstance(v, str): + return OrderExtra(**_parse_json_dict(v)) + return v or OrderExtra() + + +class OrderWithItems(BaseModel): + order: Order + items: list[OrderItemRow] + + +class OrderInvoice(BaseModel): + """Returned after a customer creates an order — pay this to confirm.""" + + order_id: str + payment_hash: str + bolt11: str + amount_msat: int + expires_at: int + + +# --------------------------------------------------------------------- # +# Print jobs # +# --------------------------------------------------------------------- # + + +class PrintJob(BaseModel): + id: str + restaurant_id: str + order_id: str + status: str = "queued" + attempts: int = 0 + last_error: Optional[str] = None + sent_at: Optional[datetime] = None + acknowledged_at: Optional[datetime] = None + time: datetime + + +# --------------------------------------------------------------------- # +# Settings # +# --------------------------------------------------------------------- # + + +class RestaurantSettings(BaseModel): + nostr_publish_enabled: bool = True + nostr_orders_enabled: bool = False + invoice_expiry_seconds: int = 900 + auto_accept_orders: bool = False