diff --git a/backend/app/schemas/ingredient.py b/backend/app/schemas/ingredient.py new file mode 100644 index 0000000..6381ca8 --- /dev/null +++ b/backend/app/schemas/ingredient.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator + + +class IngredientBase(BaseModel): + name: str = Field(min_length=1, max_length=200) + aliases: List[str] = Field(default_factory=list) + aisle: Optional[str] = Field(default=None, max_length=100) + unit: Optional[str] = Field(default=None, max_length=50) + typical_price: Optional[Decimal] = None + + @field_validator("aliases") + @classmethod + def _strip_and_drop_empty(cls, v: List[str]) -> List[str]: + return [s.strip() for s in v if s and s.strip()] + + +class IngredientCreate(IngredientBase): + pass + + +class IngredientUpdate(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=200) + aliases: Optional[List[str]] = None + aisle: Optional[str] = Field(default=None, max_length=100) + unit: Optional[str] = Field(default=None, max_length=50) + typical_price: Optional[Decimal] = None + + @field_validator("aliases") + @classmethod + def _strip_and_drop_empty(cls, v: Optional[List[str]]) -> Optional[List[str]]: + if v is None: + return None + return [s.strip() for s in v if s and s.strip()] + + +class IngredientRead(IngredientBase): + id: UUID + + model_config = {"from_attributes": True} + + +class IngredientGroceryMatchRead(BaseModel): + id: UUID + ingredient_id: UUID + grocery_item_id: UUID + confidence: Decimal + source: str + grocery_item_name: Optional[str] = None + current_price: Optional[Decimal] = None + regular_price: Optional[Decimal] = None + is_on_sale: Optional[bool] = None + + model_config = {"from_attributes": True} diff --git a/backend/tests/test_ingredient_schema.py b/backend/tests/test_ingredient_schema.py new file mode 100644 index 0000000..047251f --- /dev/null +++ b/backend/tests/test_ingredient_schema.py @@ -0,0 +1,34 @@ +from decimal import Decimal +from uuid import uuid4 + +from app.schemas.ingredient import IngredientCreate, IngredientRead, IngredientGroceryMatchRead + + +def test_ingredient_create_normalizes_aliases() -> None: + payload = IngredientCreate( + name="Chicken Thighs", + aliases=[" chicken thigh ", "BSL chicken thighs", ""], + aisle="meat_seafood", + unit="lb", + ) + assert payload.name == "Chicken Thighs" + assert payload.aliases == ["chicken thigh", "BSL chicken thighs"] + + +def test_ingredient_match_read_round_trip() -> None: + iid = uuid4() + gid = uuid4() + raw = { + "id": uuid4(), + "ingredient_id": iid, + "grocery_item_id": gid, + "confidence": Decimal("0.875"), + "source": "auto", + "grocery_item_name": "Foster Farms Chicken Thighs Family Pack", + "current_price": Decimal("3.99"), + "regular_price": Decimal("5.49"), + "is_on_sale": True, + } + parsed = IngredientGroceryMatchRead.model_validate(raw) + assert parsed.confidence == Decimal("0.875") + assert parsed.is_on_sale is True