From d24e95c09147c277b4ac75f38f7c61b19415ed5e Mon Sep 17 00:00:00 2001 From: hay-kot Date: Tue, 31 Aug 2021 14:39:29 -0800 Subject: [PATCH] feat(backend): :sparkles: add initial cookbook support --- mealie/core/dependencies/grouped.py | 2 +- .../data_access_layer/_base_access_model.py | 47 ++-- mealie/db/data_access_layer/db_access.py | 7 +- mealie/db/models/_model_utils.py | 17 +- mealie/db/models/cookbook.py | 24 ++ mealie/db/models/group.py | 17 +- mealie/db/models/recipe/category.py | 7 +- mealie/db/models/settings.py | 20 +- mealie/routes/groups/__init__.py | 3 +- mealie/routes/groups/cookbooks.py | 49 ++++ mealie/routes/recipe/recipe_crud_routes.py | 4 +- mealie/schema/cookbook/__init__.py | 1 + mealie/schema/cookbook/cookbook.py | 38 +++ mealie/schema/recipe/recipe_category.py | 1 - mealie/schema/recipe_old/__init__.py | 4 - mealie/schema/recipe_old/category.py | 48 ---- mealie/schema/recipe_old/comments.py | 44 ---- mealie/schema/recipe_old/helpers.py | 5 - mealie/schema/recipe_old/recipe.py | 243 ------------------ mealie/schema/recipe_old/units_and_foods.py | 24 -- .../base_http_service/base_http_service.py | 52 ++-- mealie/services/cookbook/__init__.py | 1 + mealie/services/cookbook/cookbook_service.py | 85 ++++++ mealie/services/recipe/recipe_service.py | 5 +- .../scraper/ingredient_nlp/pre_processor.py | 2 +- .../scraper/ingredient_nlp/processor.py | 2 +- .../services/scraper/ingredient_nlp/utils.py | 22 +- 27 files changed, 284 insertions(+), 490 deletions(-) create mode 100644 mealie/db/models/cookbook.py create mode 100644 mealie/routes/groups/cookbooks.py create mode 100644 mealie/schema/cookbook/__init__.py create mode 100644 mealie/schema/cookbook/cookbook.py delete mode 100644 mealie/schema/recipe_old/__init__.py delete mode 100644 mealie/schema/recipe_old/category.py delete mode 100644 mealie/schema/recipe_old/comments.py delete mode 100644 mealie/schema/recipe_old/helpers.py delete mode 100644 mealie/schema/recipe_old/recipe.py delete mode 100644 mealie/schema/recipe_old/units_and_foods.py create mode 100644 mealie/services/cookbook/__init__.py create mode 100644 mealie/services/cookbook/cookbook_service.py diff --git a/mealie/core/dependencies/grouped.py b/mealie/core/dependencies/grouped.py index 6bfaefe763d8..75150779da6b 100644 --- a/mealie/core/dependencies/grouped.py +++ b/mealie/core/dependencies/grouped.py @@ -24,7 +24,7 @@ class ReadDeps: user=Depends(is_logged_in), ): self.session: Session = session - self.bg_tasks: BackgroundTasks = background_tasks + self.bg_task: BackgroundTasks = background_tasks self.user: bool = user diff --git a/mealie/db/data_access_layer/_base_access_model.py b/mealie/db/data_access_layer/_base_access_model.py index 257cb41ff544..33e90f593f78 100644 --- a/mealie/db/data_access_layer/_base_access_model.py +++ b/mealie/db/data_access_layer/_base_access_model.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Callable, Generic, TypeVar, Union from sqlalchemy import func @@ -22,11 +24,8 @@ class BaseAccessModel(Generic[T, D]): def __init__(self, primary_key: Union[str, int], sql_model: D, schema: T) -> None: self.primary_key = primary_key - self.sql_model = sql_model - self.schema = schema - self.observers: list = [] def subscribe(self, func: Callable) -> None: @@ -82,40 +81,52 @@ class BaseAccessModel(Generic[T, D]): return [x.get(self.primary_key) for x in results_as_dict] def _query_one(self, session: Session, match_value: str, match_key: str = None) -> D: - """Query the sql database for one item an return the sql alchemy model + """ + Query the sql database for one item an return the sql alchemy model object. If no match key is provided the primary_key attribute will be used. - - Args: - session (Session): Database Session object - match_value (str): The value to use in the query - match_key (str, optional): the key/property to match against. Defaults to None. - - Returns: - Union[Session, SqlAlchemyBase]: Will return both the session and found model """ if match_key is None: match_key = self.primary_key return session.query(self.sql_model).filter_by(**{match_key: match_value}).one() + def get_one(self, session: Session, value: str | int, key: str = None, any_case=False, override_schema=None) -> T: + key = key or self.primary_key + + if any_case: + search_attr = getattr(self.sql_model, key) + result = session.query(self.sql_model).filter(func.lower(search_attr) == key.lower()).one_or_none() + + result = session.query(self.sql_model).filter_by(**{key: value}).one_or_none() + + if not result: + return + + eff_schema = override_schema or self.schema + return eff_schema.from_orm(result) + + def get_many( + self, session: Session, value: str, key: str = None, limit=1, any_case=False, override_schema=None + ) -> list[T]: + pass + def get( self, session: Session, match_value: str, match_key: str = None, limit=1, any_case=False, override_schema=None - ) -> Union[T, list[T]]: + ) -> T | list[T]: """Retrieves an entry from the database by matching a key/value pair. If no key is provided the class objects primary key will be used to match against. Args: - match_value (str): A value used to match against the key/value in the database \n - match_key (str, optional): They key to match the value against. Defaults to None. \n - limit (int, optional): A limit to returned responses. Defaults to 1. \n + match_value (str): A value used to match against the key/value in the database + match_key (str, optional): They key to match the value against. Defaults to None. + limit (int, optional): A limit to returned responses. Defaults to 1. Returns: dict or list[dict]: """ - if match_key is None: - match_key = self.primary_key + match_key = match_key or self.primary_key if any_case: search_attr = getattr(self.sql_model, match_key) diff --git a/mealie/db/data_access_layer/db_access.py b/mealie/db/data_access_layer/db_access.py index 280b07403341..454fe057377a 100644 --- a/mealie/db/data_access_layer/db_access.py +++ b/mealie/db/data_access_layer/db_access.py @@ -3,6 +3,7 @@ from logging import getLogger from sqlalchemy.orm.session import Session from mealie.db.data_access_layer.group_access_model import GroupDataAccessModel +from mealie.db.models.cookbook import CookBook from mealie.db.models.event import Event, EventNotification from mealie.db.models.group import Group from mealie.db.models.mealplan import MealPlan @@ -10,12 +11,12 @@ from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.comment import RecipeComment from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.recipe import RecipeModel, Tag -from mealie.db.models.settings import CustomPage, SiteSettings +from mealie.db.models.settings import SiteSettings from mealie.db.models.shopping_list import ShoppingList from mealie.db.models.sign_up import SignUp from mealie.db.models.users import LongLiveToken, User -from mealie.schema.admin import CustomPageOut from mealie.schema.admin import SiteSettings as SiteSettingsSchema +from mealie.schema.cookbook import ReadCookBook from mealie.schema.events import Event as EventSchema from mealie.schema.events import EventNotificationIn from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut @@ -73,7 +74,6 @@ class DatabaseAccessLayer: # Site self.settings = BaseAccessModel(DEFAULT_PK, SiteSettings, SiteSettingsSchema) self.sign_ups = BaseAccessModel("token", SignUp, SignUpOut) - self.custom_pages = BaseAccessModel(DEFAULT_PK, CustomPage, CustomPageOut) self.event_notifications = BaseAccessModel(DEFAULT_PK, EventNotification, EventNotificationIn) self.events = BaseAccessModel(DEFAULT_PK, Event, EventSchema) @@ -83,3 +83,4 @@ class DatabaseAccessLayer: self.groups = GroupDataAccessModel(DEFAULT_PK, Group, GroupInDB) self.meals = BaseAccessModel(DEFAULT_PK, MealPlan, MealPlanOut) self.shopping_lists = BaseAccessModel(DEFAULT_PK, ShoppingList, ShoppingListOut) + self.cookbooks = BaseAccessModel(DEFAULT_PK, CookBook, ReadCookBook) diff --git a/mealie/db/models/_model_utils.py b/mealie/db/models/_model_utils.py index 0199b186deeb..9eaee5458ab2 100644 --- a/mealie/db/models/_model_utils.py +++ b/mealie/db/models/_model_utils.py @@ -4,7 +4,7 @@ from typing import Union from sqlalchemy.orm import MANYTOMANY, MANYTOONE, ONETOMANY -def handle_one_to_many_list(relation_cls, all_elements: list[dict]): +def handle_one_to_many_list(get_attr, relation_cls, all_elements: list[dict]): elems_to_create = [] updated_elems = [] @@ -75,8 +75,17 @@ def auto_init(exclude: Union[set, list] = None): # sourcery no-metrics relation_cls = relationships[key].mapper.entity use_list = relationships[key].uselist + try: + get_attr = relation_cls.Config.get_attr + if get_attr is None: + get_attr = "id" + except Exception: + get_attr = "id" + + print(get_attr) + if relation_dir == ONETOMANY.name and use_list: - instances = handle_one_to_many_list(relation_cls, val) + instances = handle_one_to_many_list(get_attr, relation_cls, val) setattr(self, key, instances) if relation_dir == ONETOMANY.name and not use_list: @@ -85,7 +94,7 @@ def auto_init(exclude: Union[set, list] = None): # sourcery no-metrics elif relation_dir == MANYTOONE.name and not use_list: if isinstance(val, dict): - val = val.get("id") + val = val.get(get_attr) if val is None: raise ValueError(f"Expected 'id' to be provided for {key}") @@ -100,7 +109,7 @@ def auto_init(exclude: Union[set, list] = None): # sourcery no-metrics raise ValueError(f"Expected many to many input to be of type list for {key}") if len(val) > 0 and isinstance(val[0], dict): - val = [elem.get("id") for elem in val] + val = [elem.get(get_attr) for elem in val] instances = [x for x in [relation_cls.get_ref(elem, session=session) for elem in val] if x] setattr(self, key, instances) diff --git a/mealie/db/models/cookbook.py b/mealie/db/models/cookbook.py new file mode 100644 index 000000000000..0e1cf187685f --- /dev/null +++ b/mealie/db/models/cookbook.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, ForeignKey, Integer, String, orm + +from ._model_base import BaseMixins, SqlAlchemyBase +from ._model_utils import auto_init +from .recipe.category import Category, cookbooks_to_categories + + +class CookBook(SqlAlchemyBase, BaseMixins): + __tablename__ = "cookbooks" + id = Column(Integer, primary_key=True) + position = Column(Integer, nullable=False) + name = Column(String, nullable=False) + slug = Column(String, nullable=False) + categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True) + + group_id = Column(Integer, ForeignKey("groups.id")) + group = orm.relationship("Group", back_populates="cookbooks") + + @auto_init() + def __init__(self, **_) -> None: + pass + + def update(self, *args, **kwarg): + self.__init__(*args, **kwarg) diff --git a/mealie/db/models/group.py b/mealie/db/models/group.py index a0c943d814f9..0650686cce1f 100644 --- a/mealie/db/models/group.py +++ b/mealie/db/models/group.py @@ -4,6 +4,7 @@ from sqlalchemy.orm.session import Session from mealie.core.config import settings from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase +from mealie.db.models.cookbook import CookBook from mealie.db.models.recipe.category import Category, group2categories @@ -19,19 +20,9 @@ class Group(SqlAlchemyBase, BaseMixins): id = sa.Column(sa.Integer, primary_key=True) name = sa.Column(sa.String, index=True, nullable=False, unique=True) users = orm.relationship("User", back_populates="group") - mealplans = orm.relationship( - "MealPlan", - back_populates="group", - single_parent=True, - order_by="MealPlan.start_date", - ) - - shopping_lists = orm.relationship( - "ShoppingList", - back_populates="group", - single_parent=True, - ) - + mealplans = orm.relationship("MealPlan", back_populates="group", single_parent=True, order_by="MealPlan.start_date") + shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True) + cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True) categories = orm.relationship("Category", secondary=group2categories, single_parent=True) # Webhook Settings diff --git a/mealie/db/models/recipe/category.py b/mealie/db/models/recipe/category.py index 6ccfd1797f05..edcc67256d7c 100644 --- a/mealie/db/models/recipe/category.py +++ b/mealie/db/models/recipe/category.py @@ -29,10 +29,10 @@ recipes2categories = sa.Table( sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")), ) -custom_pages2categories = sa.Table( - "custom_pages2categories", +cookbooks_to_categories = sa.Table( + "cookbooks_to_categories", SqlAlchemyBase.metadata, - sa.Column("custom_page_id", sa.Integer, sa.ForeignKey("custom_pages.id")), + sa.Column("cookbook_id", sa.Integer, sa.ForeignKey("cookbooks.id")), sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")), ) @@ -61,6 +61,7 @@ class Category(SqlAlchemyBase, BaseMixins): if not session or not match_value: return None + print(match_value) slug = slugify(match_value) result = session.query(Category).filter(Category.slug == slug).one_or_none() diff --git a/mealie/db/models/settings.py b/mealie/db/models/settings.py index ca9e1c3282a9..79464f0bf3a2 100644 --- a/mealie/db/models/settings.py +++ b/mealie/db/models/settings.py @@ -3,7 +3,7 @@ import sqlalchemy.orm as orm from sqlalchemy.orm import Session from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase -from mealie.db.models.recipe.category import Category, custom_pages2categories, site_settings2categories +from mealie.db.models.recipe.category import Category, site_settings2categories class SiteSettings(SqlAlchemyBase, BaseMixins): @@ -33,21 +33,3 @@ class SiteSettings(SqlAlchemyBase, BaseMixins): def update(self, *args, **kwarg): self.__init__(*args, **kwarg) - - -class CustomPage(SqlAlchemyBase, BaseMixins): - __tablename__ = "custom_pages" - id = sa.Column(sa.Integer, primary_key=True) - position = sa.Column(sa.Integer, nullable=False) - name = sa.Column(sa.String, nullable=False) - slug = sa.Column(sa.String, nullable=False) - categories = orm.relationship("Category", secondary=custom_pages2categories, single_parent=True) - - def __init__(self, session=None, name=None, slug=None, position=0, categories=[], **_) -> None: - self.name = name - self.slug = slug - self.position = position - self.categories = [Category.get_ref(session=session, slug=cat.get("slug")) for cat in categories] - - def update(self, *args, **kwarg): - self.__init__(*args, **kwarg) diff --git a/mealie/routes/groups/__init__.py b/mealie/routes/groups/__init__.py index 57bcfb8cf0a7..544459501099 100644 --- a/mealie/routes/groups/__init__.py +++ b/mealie/routes/groups/__init__.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from . import crud +from . import cookbooks, crud router = APIRouter() +router.include_router(cookbooks.user_router) router.include_router(crud.user_router) router.include_router(crud.admin_router) diff --git a/mealie/routes/groups/cookbooks.py b/mealie/routes/groups/cookbooks.py new file mode 100644 index 000000000000..52b339a5f24d --- /dev/null +++ b/mealie/routes/groups/cookbooks.py @@ -0,0 +1,49 @@ +from fastapi import Depends + +from mealie.routes.routers import UserAPIRouter +from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook +from mealie.services.cookbook import CookbookService + +user_router = UserAPIRouter(prefix="/groups/cookbooks", tags=["Groups: Cookbooks"]) + + +@user_router.get("", response_model=list[ReadCookBook]) +def get_all_cookbook(cb_service: CookbookService = Depends(CookbookService.private)): + """ Get cookbook from the Database """ + # Get Item + return cb_service.get_all() + + +@user_router.post("", response_model=ReadCookBook) +def create_cookbook(data: CreateCookBook, cb_service: CookbookService = Depends(CookbookService.private)): + """ Create cookbook in the Database """ + # Create Item + return cb_service.create_one(data) + + +@user_router.put("", response_model=list[ReadCookBook]) +def update_many(data: list[ReadCookBook], cb_service: CookbookService = Depends(CookbookService.private)): + """ Create cookbook in the Database """ + # Create Item + return cb_service.update_many(data) + + +@user_router.get("/{id}", response_model=ReadCookBook) +def get_cookbook(cb_service: CookbookService = Depends(CookbookService.write_existing)): + """ Get cookbook from the Database """ + # Get Item + return cb_service.cookbook + + +@user_router.put("/{id}") +def update_cookbook(data: CreateCookBook, cb_service: CookbookService = Depends(CookbookService.write_existing)): + """ Update cookbook in the Database """ + # Update Item + return cb_service.update_one(data) + + +@user_router.delete("/{id}") +def delete_cookbook(cd_service: CookbookService = Depends(CookbookService.write_existing)): + """ Delete cookbook from the Database """ + # Delete Item + return cd_service.delete_one() diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 544b9a9531a7..fd4cd4ba4050 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -31,13 +31,13 @@ def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existi @user_router.post("", status_code=201, response_model=str) -def create_from_name(data: CreateRecipe, recipe_service: RecipeService = Depends(RecipeService.base)) -> str: +def create_from_name(data: CreateRecipe, recipe_service: RecipeService = Depends(RecipeService.private)) -> str: """ Takes in a JSON string and loads data into the database as a new entry""" return recipe_service.create_recipe(data).slug @user_router.post("/create-url", status_code=201, response_model=str) -def parse_recipe_url(url: CreateRecipeByURL, recipe_service: RecipeService = Depends(RecipeService.base)): +def parse_recipe_url(url: CreateRecipeByURL, recipe_service: RecipeService = Depends(RecipeService.private)): """ Takes in a URL and attempts to scrape data and load it into the database """ recipe = create_from_url(url.url) diff --git a/mealie/schema/cookbook/__init__.py b/mealie/schema/cookbook/__init__.py new file mode 100644 index 000000000000..3fae82e26ebc --- /dev/null +++ b/mealie/schema/cookbook/__init__.py @@ -0,0 +1 @@ +from .cookbook import * diff --git a/mealie/schema/cookbook/cookbook.py b/mealie/schema/cookbook/cookbook.py new file mode 100644 index 000000000000..adffcba0e56b --- /dev/null +++ b/mealie/schema/cookbook/cookbook.py @@ -0,0 +1,38 @@ +from fastapi_camelcase import CamelModel +from pydantic import validator +from slugify import slugify + +from ..recipe.recipe_category import CategoryBase + + +class CreateCookBook(CamelModel): + name: str + slug: str = None + position: int = 1 + categories: list[CategoryBase] = [] + + @validator("slug", always=True, pre=True) + def validate_slug(slug: str, values): + name: str = values["name"] + calc_slug: str = slugify(name) + + if slug != calc_slug: + slug = calc_slug + + return slug + + +class UpdateCookBook(CreateCookBook): + id: int + + +class SaveCookBook(CreateCookBook): + group_id: int + + +class ReadCookBook(UpdateCookBook): + group_id: int + categories: list[CategoryBase] = [] + + class Config: + orm_mode = True diff --git a/mealie/schema/recipe/recipe_category.py b/mealie/schema/recipe/recipe_category.py index ac2287ed2a86..7a8b36997254 100644 --- a/mealie/schema/recipe/recipe_category.py +++ b/mealie/schema/recipe/recipe_category.py @@ -19,7 +19,6 @@ class CategoryBase(CategoryIn): def getter_dict(_cls, name_orm): return { **GetterDict(name_orm), - "total_recipes": len(name_orm.recipes), } diff --git a/mealie/schema/recipe_old/__init__.py b/mealie/schema/recipe_old/__init__.py deleted file mode 100644 index 35d69062b42f..000000000000 --- a/mealie/schema/recipe_old/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .category import * -from .comments import * -from .helpers import * -from .recipe import * diff --git a/mealie/schema/recipe_old/category.py b/mealie/schema/recipe_old/category.py deleted file mode 100644 index ac2287ed2a86..000000000000 --- a/mealie/schema/recipe_old/category.py +++ /dev/null @@ -1,48 +0,0 @@ -from typing import List, Optional - -from fastapi_camelcase import CamelModel -from pydantic.utils import GetterDict - - -class CategoryIn(CamelModel): - name: str - - -class CategoryBase(CategoryIn): - id: int - slug: str - - class Config: - orm_mode = True - - @classmethod - def getter_dict(_cls, name_orm): - return { - **GetterDict(name_orm), - "total_recipes": len(name_orm.recipes), - } - - -class RecipeCategoryResponse(CategoryBase): - recipes: Optional[List["Recipe"]] - - class Config: - schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}} - - -class TagIn(CategoryIn): - pass - - -class TagBase(CategoryBase): - pass - - -class RecipeTagResponse(RecipeCategoryResponse): - pass - - -from .recipe import Recipe - -RecipeCategoryResponse.update_forward_refs() -RecipeTagResponse.update_forward_refs() diff --git a/mealie/schema/recipe_old/comments.py b/mealie/schema/recipe_old/comments.py deleted file mode 100644 index 6abd8121e013..000000000000 --- a/mealie/schema/recipe_old/comments.py +++ /dev/null @@ -1,44 +0,0 @@ -from datetime import datetime -from typing import Optional - -from fastapi_camelcase import CamelModel -from pydantic.utils import GetterDict - - -class UserBase(CamelModel): - id: int - username: Optional[str] - admin: bool - - class Config: - orm_mode = True - - -class CommentIn(CamelModel): - text: str - - -class CommentSaveToDB(CommentIn): - recipe_slug: str - user: int - - class Config: - orm_mode = True - - -class CommentOut(CommentIn): - id: int - uuid: str - recipe_slug: str - date_added: datetime - user: UserBase - - class Config: - orm_mode = True - - @classmethod - def getter_dict(_cls, name_orm): - return { - **GetterDict(name_orm), - "recipe_slug": name_orm.recipe.slug, - } diff --git a/mealie/schema/recipe_old/helpers.py b/mealie/schema/recipe_old/helpers.py deleted file mode 100644 index 85e55e91a408..000000000000 --- a/mealie/schema/recipe_old/helpers.py +++ /dev/null @@ -1,5 +0,0 @@ -from fastapi_camelcase import CamelModel - - -class RecipeSlug(CamelModel): - slug: str diff --git a/mealie/schema/recipe_old/recipe.py b/mealie/schema/recipe_old/recipe.py deleted file mode 100644 index c327c4c9bdd6..000000000000 --- a/mealie/schema/recipe_old/recipe.py +++ /dev/null @@ -1,243 +0,0 @@ -import datetime -from enum import Enum -from pathlib import Path -from typing import Any, Optional - -from fastapi_camelcase import CamelModel -from pydantic import BaseModel, Field, validator -from pydantic.utils import GetterDict -from slugify import slugify - -from mealie.core.config import app_dirs, settings -from mealie.db.models.recipe.recipe import RecipeModel - -from .comments import CommentOut -from .units_and_foods import IngredientFood, IngredientUnit - - -class CreateRecipe(CamelModel): - name: str - - -class RecipeImageTypes(str, Enum): - original = "original.webp" - min = "min-original.webp" - tiny = "tiny-original.webp" - - -class RecipeSettings(CamelModel): - public: bool = settings.RECIPE_PUBLIC - show_nutrition: bool = settings.RECIPE_SHOW_NUTRITION - show_assets: bool = settings.RECIPE_SHOW_ASSETS - landscape_view: bool = settings.RECIPE_LANDSCAPE_VIEW - disable_comments: bool = settings.RECIPE_DISABLE_COMMENTS - disable_amount: bool = settings.RECIPE_DISABLE_AMOUNT - - class Config: - orm_mode = True - - -class RecipeNote(BaseModel): - title: str - text: str - - class Config: - orm_mode = True - - -class RecipeStep(CamelModel): - title: Optional[str] = "" - text: str - - class Config: - orm_mode = True - - -class RecipeAsset(CamelModel): - name: str - icon: str - file_name: Optional[str] - - class Config: - orm_mode = True - - -class Nutrition(CamelModel): - calories: Optional[str] - fat_content: Optional[str] - protein_content: Optional[str] - carbohydrate_content: Optional[str] - fiber_content: Optional[str] - sodium_content: Optional[str] - sugar_content: Optional[str] - - class Config: - orm_mode = True - - -class RecipeIngredient(CamelModel): - title: Optional[str] - note: Optional[str] - unit: Optional[IngredientUnit] - food: Optional[IngredientFood] - disable_amount: bool = True - quantity: int = 1 - - class Config: - orm_mode = True - - -class RecipeSummary(CamelModel): - id: Optional[int] - name: Optional[str] - slug: str = "" - image: Optional[Any] - - description: Optional[str] - recipe_category: Optional[list[str]] = [] - tags: Optional[list[str]] = [] - rating: Optional[int] - - date_added: Optional[datetime.date] - date_updated: Optional[datetime.datetime] - - class Config: - orm_mode = True - - @classmethod - def getter_dict(_cls, name_orm: RecipeModel): - return { - **GetterDict(name_orm), - "recipe_category": [x.name for x in name_orm.recipe_category], - "tags": [x.name for x in name_orm.tags], - } - - -class Recipe(RecipeSummary): - recipe_yield: Optional[str] - recipe_ingredient: Optional[list[RecipeIngredient]] = [] - recipe_instructions: Optional[list[RecipeStep]] = [] - nutrition: Optional[Nutrition] - tools: Optional[list[str]] = [] - - total_time: Optional[str] = None - prep_time: Optional[str] = None - perform_time: Optional[str] = None - - # Mealie Specific - settings: Optional[RecipeSettings] = RecipeSettings() - assets: Optional[list[RecipeAsset]] = [] - notes: Optional[list[RecipeNote]] = [] - org_url: Optional[str] = Field(None, alias="orgURL") - extras: Optional[dict] = {} - - comments: Optional[list[CommentOut]] = [] - - @staticmethod - def directory_from_slug(slug) -> Path: - return app_dirs.RECIPE_DATA_DIR.joinpath(slug) - - @property - def directory(self) -> Path: - dir = app_dirs.RECIPE_DATA_DIR.joinpath(self.slug) - dir.mkdir(exist_ok=True, parents=True) - return dir - - @property - def asset_dir(self) -> Path: - dir = self.directory.joinpath("assets") - dir.mkdir(exist_ok=True, parents=True) - return dir - - @property - def image_dir(self) -> Path: - dir = self.directory.joinpath("images") - dir.mkdir(exist_ok=True, parents=True) - return dir - - class Config: - orm_mode = True - - @classmethod - def getter_dict(_cls, name_orm: RecipeModel): - return { - **GetterDict(name_orm), - # "recipe_ingredient": [x.note for x in name_orm.recipe_ingredient], - "recipe_category": [x.name for x in name_orm.recipe_category], - "tags": [x.name for x in name_orm.tags], - "tools": [x.tool for x in name_orm.tools], - "extras": {x.key_name: x.value for x in name_orm.extras}, - } - - schema_extra = { - "example": { - "name": "Chicken and Rice With Leeks and Salsa Verde", - "description": "This one-skillet dinner gets deep oniony flavor from lots of leeks cooked down to jammy tenderness.", - "image": "chicken-and-rice-with-leeks-and-salsa-verde.jpg", - "recipe_yield": "4 Servings", - "recipe_ingredient": [ - "1 1/2 lb. skinless, boneless chicken thighs (4-8 depending on size)", - "Kosher salt, freshly ground pepper", - "3 Tbsp. unsalted butter, divided", - ], - "recipe_instructions": [ - { - "text": "Season chicken with salt and pepper.", - }, - ], - "slug": "chicken-and-rice-with-leeks-and-salsa-verde", - "tags": ["favorite", "yummy!"], - "recipe_category": ["Dinner", "Pasta"], - "notes": [{"title": "Watch Out!", "text": "Prep the day before!"}], - "org_url": "https://www.bonappetit.com/recipe/chicken-and-rice-with-leeks-and-salsa-verde", - "rating": 3, - "extras": {"message": "Don't forget to defrost the chicken!"}, - } - } - - @validator("slug", always=True, pre=True) - def validate_slug(slug: str, values): - if not values["name"]: - return slug - name: str = values["name"] - calc_slug: str = slugify(name) - - if slug != calc_slug: - slug = calc_slug - - return slug - - @validator("recipe_ingredient", always=True, pre=True) - def validate_ingredients(recipe_ingredient, values): - if not recipe_ingredient or not isinstance(recipe_ingredient, list): - return recipe_ingredient - - if all(isinstance(elem, str) for elem in recipe_ingredient): - return [RecipeIngredient(note=x) for x in recipe_ingredient] - - return recipe_ingredient - - -class AllRecipeRequest(BaseModel): - properties: list[str] - limit: Optional[int] - - class Config: - schema_extra = { - "example": { - "properties": ["name", "slug", "image"], - "limit": 100, - } - } - - -class RecipeURLIn(BaseModel): - url: str - - class Config: - schema_extra = {"example": {"url": "https://myfavoriterecipes.com/recipes"}} - - -class SlugResponse(BaseModel): - class Config: - schema_extra = {"example": "adult-mac-and-cheese"} diff --git a/mealie/schema/recipe_old/units_and_foods.py b/mealie/schema/recipe_old/units_and_foods.py deleted file mode 100644 index 35a1a4106fad..000000000000 --- a/mealie/schema/recipe_old/units_and_foods.py +++ /dev/null @@ -1,24 +0,0 @@ -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 diff --git a/mealie/services/base_http_service/base_http_service.py b/mealie/services/base_http_service/base_http_service.py index 61f34512be5f..8d76fa1bcf2d 100644 --- a/mealie/services/base_http_service/base_http_service.py +++ b/mealie/services/base_http_service/base_http_service.py @@ -48,25 +48,19 @@ class BaseHttpService(Generic[T, D]): def assert_existing(self, data: T) -> None: raise NotImplementedError("`assert_existing` must by implemented by child class") + def _create_event(self, title: str, message: str) -> None: + if not self.__class__.event_func: + raise NotImplementedError("`event_func` must be set by child class") + + self.background_tasks.add_task(self.__class__.event_func, title, message, self.session) + @classmethod def read_existing(cls, id: T, deps: ReadDeps = Depends()): """ Used for dependency injection for routes that require an existing recipe. If the recipe doesn't exist or the user doens't not have the required permissions, the proper HTTP Status code will be raised. - - Args: - slug (str): Recipe Slug used to query the database - session (Session, optional): The Injected SQLAlchemy Session. - user (bool, optional): The injected determination of is_logged_in. - - Raises: - HTTPException: 404 Not Found - HTTPException: 403 Forbidden - - Returns: - RecipeService: The Recipe Service class with a populated recipe attribute """ - new_class = cls(deps.session, deps.user, deps.bg_tasks) + new_class = cls(deps.session, deps.user, deps.bg_task) new_class.assert_existing(id) return new_class @@ -75,35 +69,21 @@ class BaseHttpService(Generic[T, D]): """ Used for dependency injection for routes that require an existing recipe. The only difference between read_existing and write_existing is that the user is required to be logged in on write_existing method. - - Args: - slug (str): Recipe Slug used to query the database - session (Session, optional): The Injected SQLAlchemy Session. - user (bool, optional): The injected determination of is_logged_in. - - Raises: - HTTPException: 404 Not Found - HTTPException: 403 Forbidden - - Returns: - RecipeService: The Recipe Service class with a populated recipe attribute """ new_class = cls(deps.session, deps.user, deps.bg_task) new_class.assert_existing(id) return new_class @classmethod - def base(cls, deps: WriteDeps = Depends()): - """A Base instance to be used as a router dependency - - Raises: - HTTPException: 400 Bad Request - + def public(cls, deps: ReadDeps = Depends()): + """ + A Base instance to be used as a router dependency """ return cls(deps.session, deps.user, deps.bg_task) - def _create_event(self, title: str, message: str) -> None: - if not self.__class__.event_func: - raise NotImplementedError("`event_func` must be set by child class") - - self.background_tasks.add_task(self.__class__.event_func, title, message, self.session) + @classmethod + def private(cls, deps: WriteDeps = Depends()): + """ + A Base instance to be used as a router dependency + """ + return cls(deps.session, deps.user, deps.bg_task) diff --git a/mealie/services/cookbook/__init__.py b/mealie/services/cookbook/__init__.py new file mode 100644 index 000000000000..3c4177f13202 --- /dev/null +++ b/mealie/services/cookbook/__init__.py @@ -0,0 +1 @@ +from .cookbook_service import * diff --git a/mealie/services/cookbook/cookbook_service.py b/mealie/services/cookbook/cookbook_service.py new file mode 100644 index 000000000000..2faef9c2f77c --- /dev/null +++ b/mealie/services/cookbook/cookbook_service.py @@ -0,0 +1,85 @@ +from fastapi import HTTPException, status + +from mealie.core.root_logger import get_logger +from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, SaveCookBook +from mealie.services.base_http_service.base_http_service import BaseHttpService +from mealie.services.events import create_group_event + +logger = get_logger(module=__name__) + + +class CookbookService(BaseHttpService[str, str]): + """ + Class Methods: + `read_existing`: Reads an existing recipe from the database. + `write_existing`: Updates an existing recipe in the database. + `base`: Requires write permissions, but doesn't perform recipe checks + """ + + event_func = create_group_event + cookbook: ReadCookBook # Required for proper type hints + + _group_id_cache = None + + @property + def group_id(self): + # TODO: Populate Group in Private User Call WARNING: May require significant refactoring + if not self._group_id_cache: + group = self.db.groups.get(self.session, self.user.group, "name") + print(group) + self._group_id_cache = group.id + return self._group_id_cache + + def assert_existing(self, id: str): + self.populate_cookbook(id) + + if not self.cookbook: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + if self.cookbook.group_id != self.group_id: + raise HTTPException(status.HTTP_403_FORBIDDEN) + + def populate_cookbook(self, id): + self.cookbook = self.db.cookbooks.get(self.session, id) + + def get_all(self) -> list[ReadCookBook]: + items = self.db.cookbooks.get(self.session, self.group_id, "group_id", limit=999) + items.sort(key=lambda x: x.position) + return items + + def create_one(self, data: CreateCookBook) -> ReadCookBook: + try: + self.cookbook = self.db.cookbooks.create(self.session, SaveCookBook(group_id=self.group_id, **data.dict())) + except Exception as ex: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, detail={"message": "PAGE_CREATION_ERROR", "exception": str(ex)} + ) + + return self.cookbook + + def update_one(self, data: CreateCookBook, id: int = None) -> ReadCookBook: + if not self.cookbook: + return + + target_id = id or self.cookbook.id + self.cookbook = self.db.cookbooks.update(self.session, target_id, data) + + return self.cookbook + + def update_many(self, data: list[ReadCookBook]) -> list[ReadCookBook]: + updated = [] + + for cookbook in data: + cb = self.db.cookbooks.update(self.session, cookbook.id, cookbook) + updated.append(cb) + + return updated + + def delete_one(self, id: int = None) -> ReadCookBook: + if not self.cookbook: + return + + target_id = id or self.cookbook.id + self.cookbook = self.db.cookbooks.delete(self.session, target_id) + + return self.cookbook diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index fd4da0507eed..7f9f73830db1 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -5,7 +5,7 @@ from typing import Union from fastapi import Depends, HTTPException, status from sqlalchemy.exc import IntegrityError -from mealie.core.dependencies.grouped import WriteDeps +from mealie.core.dependencies.grouped import ReadDeps, WriteDeps from mealie.core.root_logger import get_logger from mealie.schema.recipe.recipe import CreateRecipe, Recipe from mealie.services.base_http_service.base_http_service import BaseHttpService @@ -21,6 +21,7 @@ class RecipeService(BaseHttpService[str, str]): `write_existing`: Updates an existing recipe in the database. `base`: Requires write permissions, but doesn't perform recipe checks """ + event_func = create_recipe_event recipe: Recipe # Required for proper type hints @@ -29,7 +30,7 @@ class RecipeService(BaseHttpService[str, str]): return super().write_existing(slug, deps) @classmethod - def read_existing(cls, slug: str, deps: WriteDeps = Depends()): + def read_existing(cls, slug: str, deps: ReadDeps = Depends()): return super().write_existing(slug, deps) def assert_existing(self, slug: str): diff --git a/mealie/services/scraper/ingredient_nlp/pre_processor.py b/mealie/services/scraper/ingredient_nlp/pre_processor.py index a6a5d4726c47..514fb187be1b 100644 --- a/mealie/services/scraper/ingredient_nlp/pre_processor.py +++ b/mealie/services/scraper/ingredient_nlp/pre_processor.py @@ -3,7 +3,7 @@ import unicodedata replace_abbreviations = { "cup ": "cup ", - "g ": "gram ", + " g ": "gram ", "kg ": "kilogram ", "lb ": "pound ", "ml ": "milliliter ", diff --git a/mealie/services/scraper/ingredient_nlp/processor.py b/mealie/services/scraper/ingredient_nlp/processor.py index 7e18709cf78a..6d7955b05e2d 100644 --- a/mealie/services/scraper/ingredient_nlp/processor.py +++ b/mealie/services/scraper/ingredient_nlp/processor.py @@ -55,8 +55,8 @@ def _exec_crf_test(input_text): def convert_list_to_crf_model(list_of_ingrdeint_text: list[str]): + print(list_of_ingrdeint_text) crf_output = _exec_crf_test([pre_process_string(x) for x in list_of_ingrdeint_text]) - crf_models = [CRFIngredient(**ingredient) for ingredient in utils.import_data(crf_output.split("\n"))] for model in crf_models: diff --git a/mealie/services/scraper/ingredient_nlp/utils.py b/mealie/services/scraper/ingredient_nlp/utils.py index 4b49d5908abc..4777cf1290b9 100644 --- a/mealie/services/scraper/ingredient_nlp/utils.py +++ b/mealie/services/scraper/ingredient_nlp/utils.py @@ -11,6 +11,7 @@ def cleanUnicodeFractions(s): """ Replace unicode fractions with ascii representation, preceded by a space. + "1\x215e" => "1 7/8" """ @@ -47,7 +48,7 @@ def unclump(s): def normalizeToken(s): """ - TODO: FIX THIS. We used to use the pattern.en package to singularize words, but + ToDo: FIX THIS. We used to use the pattern.en package to singularize words, but in the name of simple deployments, we took it out. We should fix this at some point. """ @@ -133,12 +134,13 @@ def insideParenthesis(token, tokens): return True else: line = " ".join(tokens) - return re.match(r".*\(.*" + re.escape(token) + r".*\).*", line) is not None + return re.match(r".*\(.*" + re.escape(token) + ".*\).*", line) is not None def displayIngredient(ingredient): """ Format a list of (tag, [tokens]) tuples as an HTML string for display. + displayIngredient([("qty", ["1"]), ("name", ["cat", "pie"])]) # => 1 cat pie """ @@ -220,21 +222,7 @@ def import_data(lines): # turn B-NAME/123 back into "name" tag, confidence = re.split(r"/", columns[-1], 1) - tag = re.sub(r"^[BI]\-", "", tag).lower() - - # TODO: Integrate Confidence into API Response - print("Confidence", confidence) - - # new token - if prevTag != tag or token == "n/a": - display[-1].append((tag, [token])) - data[-1][tag] = [] - prevTag = tag - - # continuation - else: - display[-1][-1][1].append(token) - data[-1][tag].append(token) + tag = re.sub("^[BI]\-", "", tag).lower() # ---- DISPLAY ---- # build a structure which groups each token by its tag, so we can