diff --git a/mealie/routes/recipe/all_recipe_routes.py b/mealie/routes/recipe/all_recipe_routes.py index a97bc42bdf20..abb97bd88e81 100644 --- a/mealie/routes/recipe/all_recipe_routes.py +++ b/mealie/routes/recipe/all_recipe_routes.py @@ -1,16 +1,15 @@ from fastapi import APIRouter, Depends from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.routes.deps import is_logged_in from mealie.schema.recipe import RecipeSummary -from mealie.services.recipe.all_recipes import get_all_recipes_public, get_all_recipes_user +from mealie.services.recipe.all_recipes import AllRecipesService from sqlalchemy.orm.session import Session router = APIRouter() @router.get("") -def get_recipe_summary(start=0, limit=9999, user: bool = Depends(is_logged_in)): +def get_recipe_summary(all_recipes_service: AllRecipesService.query = Depends()): """ Returns key the recipe summary data for recipes in the database. You can perform slice operations to set the skip/end amounts for recipes. All recipes are sorted by the added date. @@ -23,11 +22,7 @@ def get_recipe_summary(start=0, limit=9999, user: bool = Depends(is_logged_in)): """ - if user: - return get_all_recipes_user(limit, start) - - else: - return get_all_recipes_public(limit, start) + return all_recipes_service.get_recipes() @router.get("/summary/untagged", response_model=list[RecipeSummary]) diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 3c834cc8ff98..442c042930ff 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -2,20 +2,21 @@ import json import shutil from zipfile import ZipFile -from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, status +from fastapi import APIRouter, BackgroundTasks, Depends, File from fastapi.datastructures import UploadFile from mealie.core.config import settings from mealie.core.root_logger import get_logger from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user, is_logged_in, temporary_zip_path +from mealie.routes.deps import get_current_user, temporary_zip_path from mealie.routes.routers import UserAPIRouter from mealie.schema.recipe import CreateRecipeByURL, Recipe, RecipeImageTypes from mealie.schema.recipe.recipe import CreateRecipe from mealie.schema.user import UserInDB from mealie.services.events import create_recipe_event from mealie.services.image.image import write_image -from mealie.services.recipe.media import check_assets, delete_assets +from mealie.services.recipe.media import check_assets +from mealie.services.recipe.recipe_service import RecipeService from mealie.services.scraper.scraper import create_from_url from scrape_schema_recipe import scrape_url from sqlalchemy.orm.session import Session @@ -27,27 +28,9 @@ logger = get_logger() @user_router.post("", status_code=201, response_model=str) -def create_from_name( - background_tasks: BackgroundTasks, - data: CreateRecipe, - session: Session = Depends(generate_session), - current_user=Depends(get_current_user), -) -> str: +def create_from_name(data: CreateRecipe, recipe_service: RecipeService = Depends(RecipeService.base)) -> str: """ Takes in a JSON string and loads data into the database as a new entry""" - - data = Recipe(name=data.name) - - recipe: Recipe = db.recipes.create(session, data.dict()) - - background_tasks.add_task( - create_recipe_event, - "Recipe Created (URL)", - f"'{recipe.name}' by {current_user.full_name} \n {settings.BASE_URL}/recipe/{recipe.slug}", - session=session, - attachment=recipe.image_dir.joinpath("min-original.webp"), - ) - - return recipe.slug + return recipe_service.create_recipe(data).slug @user_router.post("/test-scrape-url") @@ -78,20 +61,10 @@ def parse_recipe_url( return recipe.slug -@public_router.get("/{recipe_slug}", response_model=Recipe) -def get_recipe(recipe_slug: str, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in)): +@public_router.get("/{slug}", response_model=Recipe) +def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existing)): """ Takes in a recipe slug, returns all data for a recipe """ - - recipe: Recipe = db.recipes.get(session, recipe_slug) - - if not recipe: - raise HTTPException(status.HTTP_404_NOT_FOUND) - if recipe.settings.public or is_user: - - return recipe - - else: - raise HTTPException(status.HTTP_403_FORBIDDEN) + return recipe_service.recipe @user_router.post("/create-from-zip") @@ -174,23 +147,7 @@ def patch_recipe( return recipe -@user_router.delete("/{recipe_slug}") -def delete_recipe( - background_tasks: BackgroundTasks, - recipe_slug: str, - session: Session = Depends(generate_session), - current_user=Depends(get_current_user), -): +@user_router.delete("/{slug}") +def delete_recipe(recipe_service: RecipeService = Depends(RecipeService.write_existing)): """ Deletes a recipe by slug """ - - try: - recipe: Recipe = db.recipes.delete(session, recipe_slug) - delete_assets(recipe_slug=recipe_slug) - except Exception: - raise HTTPException(status.HTTP_400_BAD_REQUEST) - - background_tasks.add_task( - create_recipe_event, "Recipe Deleted", f"'{recipe.name}' deleted by {current_user.full_name}", session=session - ) - - return recipe + return recipe_service.delete_recipe() diff --git a/mealie/services/recipe/all_recipes.py b/mealie/services/recipe/all_recipes.py index 19f7a3e37c04..480101a55606 100644 --- a/mealie/services/recipe/all_recipes.py +++ b/mealie/services/recipe/all_recipes.py @@ -1,16 +1,44 @@ import json from functools import lru_cache -from fastapi import Response +from fastapi import Depends, Response from fastapi.encoders import jsonable_encoder from mealie.core.root_logger import get_logger from mealie.db.database import db -from mealie.db.db_setup import SessionLocal +from mealie.db.db_setup import SessionLocal, generate_session +from mealie.routes.deps import is_logged_in from mealie.schema.recipe import RecipeSummary +from sqlalchemy.orm.session import Session logger = get_logger() +class AllRecipesService: + def __init__(self, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in)): + self.start = 0 + self.limit = 9999 + self.session = session or SessionLocal() + self.is_user = is_user + + @classmethod + def query( + cls, start=0, limit=9999, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in) + ): + set_query = cls(session, is_user) + + set_query.start = start + set_query.limit = limit + + return set_query + + def get_recipes(self): + if self.is_user: + return get_all_recipes_user(self.limit, self.start) + + else: + return get_all_recipes_public(self.limit, self.start) + + @lru_cache(maxsize=1) def get_all_recipes_user(limit, start): with SessionLocal() as session: diff --git a/mealie/services/recipe/common_deps.py b/mealie/services/recipe/common_deps.py new file mode 100644 index 000000000000..c68896728ff2 --- /dev/null +++ b/mealie/services/recipe/common_deps.py @@ -0,0 +1,40 @@ +from typing import Any + +from fastapi import BackgroundTasks, Depends +from mealie.db.db_setup import generate_session +from mealie.routes.deps import get_current_user, is_logged_in +from pydantic import BaseModel +from sqlalchemy.orm.session import Session + + +class CommonDeps(BaseModel): + session: Session + background_tasks: BackgroundTasks + user: Any + + class Config: + arbitrary_types_allowed = True + + +def _read_deps( + background_tasks: BackgroundTasks, + session: Session = Depends(generate_session), + current_user=Depends(is_logged_in), +): + return CommonDeps( + session=session, + background_tasks=background_tasks, + user=current_user, + ) + + +def _write_deps( + background_tasks: BackgroundTasks, + session: Session = Depends(generate_session), + current_user=Depends(get_current_user), +): + return CommonDeps( + session=session, + background_tasks=background_tasks, + user=current_user, + ) diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py new file mode 100644 index 000000000000..56337d686011 --- /dev/null +++ b/mealie/services/recipe/recipe_service.py @@ -0,0 +1,136 @@ +from fastapi import BackgroundTasks, Depends, HTTPException, status +from mealie.core.config import get_settings +from mealie.db.database import get_database +from mealie.db.db_setup import SessionLocal +from mealie.schema.recipe.recipe import CreateRecipe, Recipe +from mealie.schema.user.user import UserInDB +from mealie.services.events import create_recipe_event +from mealie.services.recipe.media import delete_assets +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.session import Session + +from .common_deps import CommonDeps, _read_deps, _write_deps + + +class RecipeService: + recipe: Recipe + + def __init__(self, session: Session, user: UserInDB, background_tasks: BackgroundTasks = None) -> None: + self.session = session or SessionLocal() + self.user = user + self.background_tasks = background_tasks + self.recipe: Recipe = None + + # Static Globals + self.db = get_database() + self.settings = get_settings() + + @classmethod + def read_existing(cls, slug: str, local_deps: CommonDeps = Depends(_read_deps)): + """ + 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(local_deps.session, local_deps.user, local_deps.background_tasks) + new_class.assert_existing(slug) + return new_class + + @classmethod + def write_existing(cls, slug: str, local_deps: CommonDeps = Depends(_write_deps)): + """ + 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(local_deps.session, local_deps.user, local_deps.background_tasks) + new_class.assert_existing(slug) + return new_class + + @classmethod + def base(cls, local_deps: CommonDeps = Depends(_write_deps)) -> Recipe: + """A Base instance to be used as a router dependency + + Raises: + HTTPException: 400 Bad Request + + """ + return cls(local_deps.session, local_deps.user, local_deps.background_tasks) + + def pupulate_recipe(self, slug: str) -> Recipe: + """Populates the recipe attribute with the recipe from the database. + + Returns: + Recipe: The populated recipe + """ + self.recipe = self.db.recipes.get(self.session, slug) + return self.recipe + + def assert_existing(self, slug: str): + self.pupulate_recipe(slug) + + if not self.recipe: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + if not self.recipe.settings.public and not self.user: + raise HTTPException(status.HTTP_403_FORBIDDEN) + + # CRUD METHODS + def create_recipe(self, new_recipe: CreateRecipe) -> Recipe: + + try: + create_data = Recipe(name=new_recipe.name) + self.recipe = self.db.recipes.create(self.session, create_data) + except IntegrityError: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"}) + + self._create_event( + "Recipe Created (URL)", + f"'{self.recipe.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.recipe.slug}", + ) + + return self.recipe + + def delete_recipe(self) -> Recipe: + """removes a recipe from the database and purges the existing files from the filesystem. + + Raises: + HTTPException: 400 Bad Request + + Returns: + Recipe: The deleted recipe + """ + + try: + recipe: Recipe = self.db.recipes.delete(self.session, self.recipe.slug) + delete_assets(recipe_slug=self.recipe.slug) + except Exception: + raise HTTPException(status.HTTP_400_BAD_REQUEST) + + self._create_event("Recipe Delete", f"'{recipe.name}' deleted by {self.user.full_name}") + return recipe + + def _create_event(self, title: str, message: str) -> None: + self.background_tasks.add_task(create_recipe_event, title, message, self.session)