diff --git a/AppLambda/src/models/mealie.py b/AppLambda/src/models/mealie.py index f3f5349..e712eca 100644 --- a/AppLambda/src/models/mealie.py +++ b/AppLambda/src/models/mealie.py @@ -1,6 +1,7 @@ import json from datetime import datetime from enum import Enum +from fractions import Fraction from json import JSONDecodeError from typing import Any @@ -10,6 +11,20 @@ from ..models.core import BaseSyncEvent, Source from ._base import APIBase +INGREDIENT_QTY_PRECISION = 3 +MAX_INGREDIENT_DENOMINATOR = 32 + +SUPERSCRIPT = dict(zip("1234567890", "¹²³⁴⁵⁶⁷⁸⁹⁰", strict=False)) +SUBSCRIPT = dict(zip("1234567890", "₁₂₃₄₅₆₇₈₉₀", strict=False)) + + +def display_fraction(fraction: Fraction): + return ( + "".join([SUPERSCRIPT[c] for c in str(fraction.numerator)]) + + "/" + + "".join([SUBSCRIPT[c] for c in str(fraction.denominator)]) + ) + class MealieBase(APIBase): @classmethod @@ -72,6 +87,7 @@ def __str__(self) -> str: class UnitFoodBase(MealieBase): name: str + plural_name: str | None = None description: str = "" extras: dict | None = {} @@ -80,6 +96,7 @@ class Unit(UnitFoodBase): id: str | None fraction: bool abbreviation: str + plural_abbreviation: str | None = "" use_abbreviation: bool | None def __str__(self) -> str: @@ -139,6 +156,7 @@ class MealieShoppingListItemBase(MealieBase): checked: bool = False position: int | None = None + disable_amount: bool | None = None is_food: bool = False note: str | None = "" @@ -181,6 +199,111 @@ class MealieShoppingListItemOut(MealieShoppingListItemBase): position: int recipe_references: list[MealieShoppingListItemRecipeRefOut] = [] + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + # calculate missing is_food and disable_amount values + # we can't do this in a validator since they depend on each other + if self.is_food is None and self.disable_amount is not None: + self.is_food = not self.disable_amount + elif self.disable_amount is None and self.is_food is not None: + self.disable_amount = not self.is_food + elif self.is_food is None and self.disable_amount is None: + self.is_food = bool(self.food) + self.disable_amount = not self.is_food + + # format the display property, if Mealie doesn't give us one + if not self.display: + self.display = self._format_display() + + def _format_quantity_for_display(self) -> str: + """How the quantity should be displayed""" + + qty: float | Fraction + + # decimal + if not self.unit or not self.unit.fraction: + qty = round(self.quantity or 0, INGREDIENT_QTY_PRECISION) + if qty.is_integer(): + return str(int(qty)) + + else: + return str(qty) + + # fraction + qty = Fraction(self.quantity or 0).limit_denominator(MAX_INGREDIENT_DENOMINATOR) + if qty.denominator == 1: + return str(qty.numerator) + + if qty.numerator <= qty.denominator: + return display_fraction(qty) + + # convert an improper fraction into a mixed fraction (e.g. 11/4 --> 2 3/4) + whole_number = 0 + while qty.numerator > qty.denominator: + whole_number += 1 + qty -= 1 + + return f"{whole_number} {display_fraction(qty)}" + + def _format_unit_for_display(self) -> str: + if not self.unit: + return "" + + use_plural = self.quantity and self.quantity > 1 + unit_val = "" + if self.unit.use_abbreviation: + if use_plural: + unit_val = self.unit.plural_abbreviation or self.unit.abbreviation + else: + unit_val = self.unit.abbreviation + + if not unit_val: + if use_plural: + unit_val = self.unit.plural_name or self.unit.name + else: + unit_val = self.unit.name + + return unit_val + + def _format_food_for_display(self) -> str: + if not self.food: + return "" + + use_plural = (not self.quantity) or self.quantity > 1 + if use_plural: + return self.food.plural_name or self.food.name + else: + return self.food.name + + def _format_display(self) -> str: + components = [] + + use_food = True + if self.is_food is False: + use_food = False + elif self.disable_amount is True: + use_food = False + + # ingredients with no food come across with a qty of 1, which looks weird + # e.g. "1 2 tbsp of olive oil" + if self.quantity and (use_food or self.quantity != 1): + components.append(self._format_quantity_for_display()) + + if not use_food: + components.append(self.note or "") + else: + if self.quantity and self.unit: + components.append(self._format_unit_for_display()) + + if self.food: + components.append(self._format_food_for_display()) + + if self.note: + components.append(self.note) + + return " ".join(components).strip() + class MealieShoppingListItemsCollectionOut(MealieBase): """Container for bulk shopping list item changes""" diff --git a/tests/model_tests/test_mealie_models.py b/tests/model_tests/test_mealie_models.py new file mode 100644 index 0000000..599efe3 --- /dev/null +++ b/tests/model_tests/test_mealie_models.py @@ -0,0 +1,50 @@ +import pytest +from AppLambda.src.models.mealie import Food, MealieShoppingListItemOut, Unit +from tests.utils.generators import random_bool, random_string, random_int + + +@pytest.mark.parametrize("use_fraction", [True, False]) +@pytest.mark.parametrize("use_food", [True, False]) +def test_mealie_shopping_list_generates_missing_display(use_fraction: bool, use_food: bool): + food = Food(name=random_string()) + unit = Unit(name=random_string(), fraction=use_fraction, abbreviation=random_string(), use_abbreviation=False) + list_item = MealieShoppingListItemOut( + id=random_string(), + display="", + shopping_list_id=random_string(), + checked=random_bool(), + position=random_int(1, 100), + disable_amount=None, + is_food=use_food, + note=random_string(), + quantity=random_int(1, 100) + 0.5, + food=food, + unit=unit, + ) + + qty_display = (str(int(list_item.quantity)) + " ¹/₂") if use_fraction else str(list_item.quantity) + if use_food: + assert list_item.display == f"{qty_display} {unit.name} {food.name} {list_item.note}" + else: + assert list_item.display == f"{qty_display} {list_item.note}" + + +def test_mealie_shopping_list_not_overwrite_existing_display(): + food = Food(name=random_string()) + unit = Unit(name=random_string(), fraction=True, abbreviation=random_string(), use_abbreviation=False) + display = random_string() + list_item = MealieShoppingListItemOut( + id=random_string(), + display=display, + shopping_list_id=random_string(), + checked=random_bool(), + position=random_int(1, 100), + disable_amount=None, + is_food=True, + note=random_string(), + quantity=random_int(1, 100) + 0.5, + food=food, + unit=unit, + ) + + assert list_item.display == display