From 122d35ec092ba56422a9253c398dbc12e43f44d7 Mon Sep 17 00:00:00 2001 From: hay-kot Date: Sun, 22 Aug 2021 13:10:18 -0800 Subject: [PATCH] feat(backend): :card_file_box: Add CRUD opertaions for Food and Units --- mealie/db/data_initialization/__init__.py | 0 .../data_initialization/init_units_foods.py | 34 ++++++ mealie/db/models/_all_models.py | 18 +-- mealie/db/models/_model_utils.py | 112 ++++++++++++++++++ mealie/db/models/recipe/ingredient.py | 53 +++------ mealie/routes/unit_and_foods/food_routes.py | 48 +++++--- mealie/routes/unit_and_foods/unit_routes.py | 48 +++++--- mealie/schema/recipe/recipe.py | 17 +-- mealie/schema/recipe/units_and_foods.py | 24 ++++ 9 files changed, 255 insertions(+), 99 deletions(-) create mode 100644 mealie/db/data_initialization/__init__.py create mode 100644 mealie/db/data_initialization/init_units_foods.py create mode 100644 mealie/db/models/_model_utils.py create mode 100644 mealie/schema/recipe/units_and_foods.py diff --git a/mealie/db/data_initialization/__init__.py b/mealie/db/data_initialization/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/mealie/db/data_initialization/init_units_foods.py b/mealie/db/data_initialization/init_units_foods.py new file mode 100644 index 000000000000..007d0873ddb0 --- /dev/null +++ b/mealie/db/data_initialization/init_units_foods.py @@ -0,0 +1,34 @@ +from mealie.schema.recipe.recipe import IngredientUnit +from sqlalchemy.orm.session import Session + +from ..data_access_layer import DatabaseAccessLayer + + +def get_default_units(): + return [ + # Volume + IngredientUnit(name="teaspoon", abbreviation="tsp"), + IngredientUnit(name="tablespoon", abbreviation="tbsp"), + IngredientUnit(name="fluid ounce", abbreviation="fl oz"), + IngredientUnit(name="cup", abbreviation="cup"), + IngredientUnit(name="pint", abbreviation="pt"), + IngredientUnit(name="quart", abbreviation="qt"), + IngredientUnit(name="gallon", abbreviation="gal"), + IngredientUnit(name="milliliter", abbreviation="ml"), + IngredientUnit(name="liter", abbreviation="l"), + # Mass Weight + IngredientUnit(name="pound", abbreviation="lb"), + IngredientUnit(name="ounce", abbreviation="oz"), + IngredientUnit(name="gram", abbreviation="g"), + IngredientUnit(name="kilogram", abbreviation="kg"), + IngredientUnit(name="milligram", abbreviation="mg"), + ] + + +def default_recipe_unit_init(db: DatabaseAccessLayer, session: Session) -> None: + for unit in get_default_units(): + try: + db.ingredient_units.create(session, unit) + print("Ingredient Unit Committed") + except Exception as e: + print(e) diff --git a/mealie/db/models/_all_models.py b/mealie/db/models/_all_models.py index c38ae9e4ac56..7351c4614fcc 100644 --- a/mealie/db/models/_all_models.py +++ b/mealie/db/models/_all_models.py @@ -1,9 +1,9 @@ -from mealie.db.models.event import * -from mealie.db.models.group import * -from mealie.db.models.mealplan import * -from mealie.db.models.recipe.recipe import * -from mealie.db.models.settings import * -from mealie.db.models.shopping_list import * -from mealie.db.models.sign_up import * -from mealie.db.models.theme import * -from mealie.db.models.users import * +from .event import * +from .group import * +from .mealplan import * +from .recipe.recipe import * +from .settings import * +from .shopping_list import * +from .sign_up import * +from .theme import * +from .users import * diff --git a/mealie/db/models/_model_utils.py b/mealie/db/models/_model_utils.py new file mode 100644 index 000000000000..4a438675098d --- /dev/null +++ b/mealie/db/models/_model_utils.py @@ -0,0 +1,112 @@ +from functools import wraps +from typing import Union + +from sqlalchemy.orm import MANYTOMANY, MANYTOONE, ONETOMANY + + +def handle_one_to_many_list(relation_cls, all_elements: list[dict]): + elems_to_create = [] + updated_elems = [] + + for elem in all_elements: + elem_id = elem.get("id", None) + + existing_elem = relation_cls.get_ref(match_value=elem_id) + + if existing_elem is None: + + elems_to_create.append(elem) + + else: + for key, value in elem.items(): + setattr(existing_elem, key, value) + + updated_elems.append(existing_elem) + + new_elems = [] + for elem in elems_to_create: + new_elems = [relation_cls(**elem) for elem in all_elements] + + return new_elems + + +def auto_init(exclude: Union[set, list] = None): # sourcery no-metrics + """Wraps the `__init__` method of a class to automatically set the common + attributes. + + Args: + exclude (Union[set, list], optional): [description]. Defaults to None. + """ + + exclude = exclude or set() + exclude.add("id") + + def decorator(init): + @wraps(init) + def wrapper(self, *args, **kwargs): # sourcery no-metrics + """ + Custom initializer that allows nested children initialization. + Only keys that are present as instance's class attributes are allowed. + These could be, for example, any mapped columns or relationships. + + Code inspired from GitHub. + Ref: https://github.com/tiangolo/fastapi/issues/2194 + """ + cls = self.__class__ + model_columns = self.__mapper__.columns + relationships = self.__mapper__.relationships + + for key, val in kwargs.items(): + if key in exclude: + continue + + if not hasattr(cls, key): + continue + # raise TypeError(f"Invalid keyword argument: {key}") + + if key in model_columns: + setattr(self, key, val) + continue + + if key in relationships: + relation_dir = relationships[key].direction.name + relation_cls = relationships[key].mapper.entity + use_list = relationships[key].uselist + + if relation_dir == ONETOMANY.name and use_list: + instances = handle_one_to_many_list(relation_cls, val) + setattr(self, key, instances) + + if relation_dir == ONETOMANY.name and not use_list: + instance = relation_cls(**val) + setattr(self, key, instance) + + elif relation_dir == MANYTOONE.name and not use_list: + if isinstance(val, dict): + val = val.get("id") + + if val is None: + raise ValueError( + f"Expected 'id' to be provided for {key}" + ) + + if isinstance(val, (str, int)): + instance = relation_cls.get_ref(match_value=val) + setattr(self, key, instance) + + elif relation_dir == MANYTOMANY.name: + if not isinstance(val, list): + raise ValueError( + f"Expected many to many input to be of type list for {key}" + ) + + if isinstance(val[0], dict): + val = [elem.get("id") for elem in val] + intstances = [relation_cls.get_ref(elem) for elem in val] + setattr(self, key, intstances) + + return init(self, *args, **kwargs) + + return wrapper + + return decorator diff --git a/mealie/db/models/recipe/ingredient.py b/mealie/db/models/recipe/ingredient.py index a9a0f85549ce..7235f7522448 100644 --- a/mealie/db/models/recipe/ingredient.py +++ b/mealie/db/models/recipe/ingredient.py @@ -2,6 +2,8 @@ from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase from requests import Session from sqlalchemy import Column, ForeignKey, Integer, String, Table, orm +from .._model_utils import auto_init + ingredients_to_units = Table( "ingredients_to_units", SqlAlchemyBase.metadata, @@ -17,54 +19,29 @@ ingredients_to_foods = Table( ) -class IngredientUnit(SqlAlchemyBase, BaseMixins): +class IngredientUnitModel(SqlAlchemyBase, BaseMixins): __tablename__ = "ingredient_units" id = Column(Integer, primary_key=True) name = Column(String) description = Column(String) + abbreviation = Column(String) ingredients = orm.relationship("RecipeIngredient", secondary=ingredients_to_units, back_populates="unit") - def __init__(self, name: str, description: str = None) -> None: - self.name = name - self.description = description - - @classmethod - def get_ref_or_create(cls, session: Session, obj: dict): - # sourcery skip: flip-comparison - if obj is None: - return None - - name = obj.get("name") - - unit = session.query(cls).filter("name" == name).one_or_none() - - if not unit: - return cls(**obj) + @auto_init() + def __init__(self, **_) -> None: + pass -class IngredientFood(SqlAlchemyBase, BaseMixins): +class IngredientFoodModel(SqlAlchemyBase, BaseMixins): __tablename__ = "ingredient_foods" id = Column(Integer, primary_key=True) name = Column(String) description = Column(String) ingredients = orm.relationship("RecipeIngredient", secondary=ingredients_to_foods, back_populates="food") - def __init__(self, name: str, description: str = None) -> None: - self.name = name - self.description = description - - @classmethod - def get_ref_or_create(cls, session: Session, obj: dict): - # sourcery skip: flip-comparison - if obj is None: - return None - - name = obj.get("name") - - unit = session.query(cls).filter("name" == name).one_or_none() - - if not unit: - return cls(**obj) + @auto_init() + def __init__(self, **_) -> None: + pass class RecipeIngredient(SqlAlchemyBase): @@ -77,8 +54,8 @@ class RecipeIngredient(SqlAlchemyBase): note = Column(String) # Force Show Text - Overrides Concat # Scaling Items - unit = orm.relationship(IngredientUnit, secondary=ingredients_to_units, uselist=False) - food = orm.relationship(IngredientFood, secondary=ingredients_to_foods, uselist=False) + unit = orm.relationship(IngredientUnitModel, secondary=ingredients_to_units, uselist=False) + food = orm.relationship(IngredientFoodModel, secondary=ingredients_to_foods, uselist=False) quantity = Column(Integer) # Extras @@ -86,6 +63,6 @@ class RecipeIngredient(SqlAlchemyBase): def __init__(self, title: str, note: str, unit: dict, food: dict, quantity: int, session: Session, **_) -> None: self.title = title self.note = note - self.unit = IngredientUnit.get_ref_or_create(session, unit) - self.food = IngredientFood.get_ref_or_create(session, food) + self.unit = IngredientUnitModel.get_ref_or_create(session, unit) + self.food = IngredientFoodModel.get_ref_or_create(session, food) self.quantity = quantity diff --git a/mealie/routes/unit_and_foods/food_routes.py b/mealie/routes/unit_and_foods/food_routes.py index 4852bf9c472e..4cf3bf969fb4 100644 --- a/mealie/routes/unit_and_foods/food_routes.py +++ b/mealie/routes/unit_and_foods/food_routes.py @@ -1,33 +1,43 @@ -from mealie.core.root_logger import get_logger +from fastapi import Depends, status +from mealie.db.database import db +from mealie.db.db_setup import Session, generate_session from mealie.routes.routers import UserAPIRouter +from mealie.schema.recipe.units_and_foods import CreateIngredientFood, IngredientFood router = UserAPIRouter() -logger = get_logger() -@router.post("") -async def create_food(): - """ Create food in the Database """ - # Create food - pass +@router.get("", response_model=list[IngredientFood]) +async def get_all( + session: Session = Depends(generate_session), +): + """ Get unit from the Database """ + # Get unit + return db.ingredient_foods.get_all(session) + + +@router.post("", response_model=IngredientFood, status_code=status.HTTP_201_CREATED) +async def create_unit(unit: CreateIngredientFood, session: Session = Depends(generate_session)): + """ Create unit in the Database """ + + return db.ingredient_foods.create(session, unit) @router.get("/{id}") -async def get_food(): - """ Get food from the Database """ - # Get food - pass +async def get_unit(id: str, session: Session = Depends(generate_session)): + """ Get unit from the Database """ + + return db.ingredient_foods.get(session, id) @router.put("/{id}") -async def update_food(): - """ Update food in the Database """ - # Update food - pass +async def update_unit(id: str, unit: CreateIngredientFood, session: Session = Depends(generate_session)): + """ Update unit in the Database """ + + return db.ingredient_foods.update(session, id, unit) @router.delete("/{id}") -async def delete_food(): - """ Delete food from the Database """ - # Delete food - pass +async def delete_unit(id: str, session: Session = Depends(generate_session)): + """ Delete unit from the Database """ + return db.ingredient_foods.delete(session, id) diff --git a/mealie/routes/unit_and_foods/unit_routes.py b/mealie/routes/unit_and_foods/unit_routes.py index 4852bf9c472e..e560436cd237 100644 --- a/mealie/routes/unit_and_foods/unit_routes.py +++ b/mealie/routes/unit_and_foods/unit_routes.py @@ -1,33 +1,43 @@ -from mealie.core.root_logger import get_logger +from fastapi import Depends, status +from mealie.db.database import db +from mealie.db.db_setup import Session, generate_session from mealie.routes.routers import UserAPIRouter +from mealie.schema.recipe.units_and_foods import CreateIngredientUnit, IngredientUnit router = UserAPIRouter() -logger = get_logger() -@router.post("") -async def create_food(): - """ Create food in the Database """ - # Create food - pass +@router.get("", response_model=list[IngredientUnit]) +async def get_all( + session: Session = Depends(generate_session), +): + """ Get unit from the Database """ + # Get unit + return db.ingredient_units.get_all(session) + + +@router.post("", response_model=IngredientUnit, status_code=status.HTTP_201_CREATED) +async def create_unit(unit: CreateIngredientUnit, session: Session = Depends(generate_session)): + """ Create unit in the Database """ + + return db.ingredient_units.create(session, unit) @router.get("/{id}") -async def get_food(): - """ Get food from the Database """ - # Get food - pass +async def get_unit(id: str, session: Session = Depends(generate_session)): + """ Get unit from the Database """ + + return db.ingredient_units.get(session, id) @router.put("/{id}") -async def update_food(): - """ Update food in the Database """ - # Update food - pass +async def update_unit(id: str, unit: CreateIngredientUnit, session: Session = Depends(generate_session)): + """ Update unit in the Database """ + + return db.ingredient_units.update(session, id, unit) @router.delete("/{id}") -async def delete_food(): - """ Delete food from the Database """ - # Delete food - pass +async def delete_unit(id: str, session: Session = Depends(generate_session)): + """ Delete unit from the Database """ + return db.ingredient_units.delete(session, id) diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 6baa3222f498..3a55d774f0a2 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -11,6 +11,7 @@ from pydantic.utils import GetterDict from slugify import slugify from .comments import CommentOut +from .units_and_foods import IngredientFood, IngredientUnit class CreateRecipe(CamelModel): @@ -73,23 +74,11 @@ class Nutrition(CamelModel): orm_mode = True -class RecipeIngredientFood(CamelModel): - name: str = "" - description: str = "" - - class Config: - orm_mode = True - - -class RecipeIngredientUnit(RecipeIngredientFood): - pass - - class RecipeIngredient(CamelModel): title: Optional[str] note: Optional[str] - unit: Optional[RecipeIngredientUnit] - food: Optional[RecipeIngredientFood] + unit: Optional[IngredientUnit] + food: Optional[IngredientFood] disable_amount: bool = True quantity: int = 1 diff --git a/mealie/schema/recipe/units_and_foods.py b/mealie/schema/recipe/units_and_foods.py new file mode 100644 index 000000000000..35a1a4106fad --- /dev/null +++ b/mealie/schema/recipe/units_and_foods.py @@ -0,0 +1,24 @@ +from fastapi_camelcase import CamelModel + + +class CreateIngredientFood(CamelModel): + name: str + description: str = "" + + +class CreateIngredientUnit(CreateIngredientFood): + abbreviation: str = "" + + +class IngredientFood(CreateIngredientFood): + id: int + + class Config: + orm_mode = True + + +class IngredientUnit(CreateIngredientUnit): + id: int + + class Config: + orm_mode = True