Refactor/conver to controllers (#923)

* add dependency injection for get_repositories

* convert events api to controller

* update generic typing

* add abstract controllers

* update test naming

* migrate admin services to controllers

* add additional admin route tests

* remove print

* add public shared dependencies

* add types

* fix typo

* add static variables for recipe json keys

* add coverage gutters config

* update controller routers

* add generic success response

* add category/tag/tool tests

* add token refresh test

* add coverage utilities

* covert comments to controller

* add todo

* add helper properties

* delete old service

* update test notes

* add unit test for pretty_stats

* remove dead code from post_webhooks

* update group routes to use controllers

* add additional group test coverage

* abstract common permission checks

* convert ingredient parser to controller

* update recipe crud to use controller

* remove dead-code

* add class lifespan tracker for debugging

* convert bulk export to controller

* migrate tools router to controller

* update recipe share to controller

* move customer router to _base

* ignore prints in flake8

* convert units and foods to new controllers

* migrate user routes to controllers

* centralize error handling

* fix invalid ref

* reorder fields

* update routers to share common handling

* update tests

* remove prints

* fix cookbooks delete

* fix cookbook get

* add controller for mealplanner

* cover report routes to controller

* remove __future__ imports

* remove dead code

* remove all base_http children and remove dead code
This commit is contained in:
Hayden
2022-01-13 13:06:52 -09:00
committed by GitHub
parent 5823a32daf
commit c4540f1395
164 changed files with 3111 additions and 3213 deletions

View File

@@ -1,40 +0,0 @@
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_step import RecipeStep
from mealie.schema.user.user import PrivateUser
step_text = """Recipe steps as well as other fields in the recipe page support markdown syntax.
**Add a link**
[My Link](https://beta.mealie.io)
**Imbed an image**
Use the `height="100"` or `width="100"` attributes to set the size of the image.
<img height="100" src="https://images.unsplash.com/photo-1567620905732-2d1ec7ab7445?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=960&q=80"></img>
"""
ingredient_note = "1 Cup Flour"
def recipe_creation_factory(user: PrivateUser, name: str, additional_attrs: dict = None) -> Recipe:
"""
The main creation point for recipes. The factor method returns an instance of the
Recipe Schema class with the appropriate defaults set. Recipes shoudld not be created
else-where to avoid conflicts.
"""
additional_attrs = additional_attrs or {}
additional_attrs["name"] = name
additional_attrs["user_id"] = user.id
additional_attrs["group_id"] = user.group_id
if not additional_attrs.get("recipe_ingredient"):
additional_attrs["recipe_ingredient"] = [RecipeIngredient(note=ingredient_note)]
if not additional_attrs.get("recipe_instructions"):
additional_attrs["recipe_instructions"] = [RecipeStep(text=step_text)]
return Recipe(**additional_attrs)

View File

@@ -1,33 +1,29 @@
from __future__ import annotations
from pathlib import Path
from mealie.core.root_logger import get_logger
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.recipe import CategoryBase, Recipe
from mealie.schema.recipe import CategoryBase
from mealie.schema.recipe.recipe_category import TagBase
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
from mealie.schema.user.user import GroupInDB, PrivateUser
from mealie.services._base_service import BaseService
from mealie.services.exporter import Exporter, RecipeExporter
logger = get_logger(__name__)
class RecipeBulkActions(UserHttpService[int, Recipe]):
event_func = create_recipe_event
_restrict_by_group = True
def populate_item(self, _: int) -> Recipe:
return
class RecipeBulkActionsService(BaseService):
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB):
self.repos = repos
self.user = user
self.group = group
super().__init__()
def export_recipes(self, temp_path: Path, slugs: list[str]) -> None:
recipe_exporter = RecipeExporter(self.db, self.group_id, slugs)
exporter = Exporter(self.group_id, temp_path, [recipe_exporter])
recipe_exporter = RecipeExporter(self.repos, self.group.id, slugs)
exporter = Exporter(self.group.id, temp_path, [recipe_exporter])
exporter.run(self.db)
exporter.run(self.repos)
def get_exports(self) -> list[GroupDataExport]:
return self.db.group_exports.multi_query({"group_id": self.group_id})
return self.repos.group_exports.multi_query({"group_id": self.group.id})
def purge_exports(self) -> int:
all_exports = self.get_exports()
@@ -36,13 +32,13 @@ class RecipeBulkActions(UserHttpService[int, Recipe]):
for export in all_exports:
try:
Path(export.path).unlink(missing_ok=True)
self.db.group_exports.delete(export.id)
self.repos.group_exports.delete(export.id)
exports_deleted += 1
except Exception as e:
logger.error(f"Failed to delete export {export.id}")
logger.error(e)
self.logger.error(f"Failed to delete export {export.id}")
self.logger.error(e)
group = self.db.groups.get_one(self.group_id)
group = self.repos.groups.get_one(self.group.id)
for match in group.directory.glob("**/export/*zip"):
if match.is_file():
@@ -53,38 +49,38 @@ class RecipeBulkActions(UserHttpService[int, Recipe]):
def assign_tags(self, recipes: list[str], tags: list[TagBase]) -> None:
for slug in recipes:
recipe = self.db.recipes.get_one(slug)
recipe = self.repos.recipes.get_one(slug)
if recipe is None:
logger.error(f"Failed to tag recipe {slug}, no recipe found")
self.logger.error(f"Failed to tag recipe {slug}, no recipe found")
recipe.tags += tags
try:
self.db.recipes.update(slug, recipe)
self.repos.recipes.update(slug, recipe)
except Exception as e:
logger.error(f"Failed to tag recipe {slug}")
logger.error(e)
self.logger.error(f"Failed to tag recipe {slug}")
self.logger.error(e)
def assign_categories(self, recipes: list[str], categories: list[CategoryBase]) -> None:
for slug in recipes:
recipe = self.db.recipes.get_one(slug)
recipe = self.repos.recipes.get_one(slug)
if recipe is None:
logger.error(f"Failed to categorize recipe {slug}, no recipe found")
self.logger.error(f"Failed to categorize recipe {slug}, no recipe found")
recipe.recipe_category += categories
try:
self.db.recipes.update(slug, recipe)
self.repos.recipes.update(slug, recipe)
except Exception as e:
logger.error(f"Failed to categorize recipe {slug}")
logger.error(e)
self.logger.error(f"Failed to categorize recipe {slug}")
self.logger.error(e)
def delete_recipes(self, recipes: list[str]) -> None:
for slug in recipes:
try:
self.db.recipes.delete(slug)
self.repos.recipes.delete(slug)
except Exception as e:
logger.error(f"Failed to delete recipe {slug}")
logger.error(e)
self.logger.error(f"Failed to delete recipe {slug}")
self.logger.error(e)

View File

@@ -1,52 +0,0 @@
from __future__ import annotations
from functools import cached_property
from uuid import UUID
from fastapi import HTTPException
from mealie.schema.recipe.recipe_comments import (
RecipeCommentCreate,
RecipeCommentOut,
RecipeCommentSave,
RecipeCommentUpdate,
)
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeCommentsService(
CrudHttpMixins[RecipeCommentOut, RecipeCommentCreate, RecipeCommentCreate],
UserHttpService[UUID, RecipeCommentOut],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = RecipeCommentOut
@cached_property
def repo(self):
return self.db.comments
def _check_comment_belongs_to_user(self) -> None:
if self.item.user_id != self.user.id and not self.user.admin:
raise HTTPException(detail="Comment does not belong to user")
def populate_item(self, id: UUID) -> RecipeCommentOut:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[RecipeCommentOut]:
return self.repo.get_all()
def create_one(self, data: RecipeCommentCreate) -> RecipeCommentOut:
save_data = RecipeCommentSave(text=data.text, user_id=self.user.id, recipe_id=data.recipe_id)
return self._create_one(save_data)
def update_one(self, data: RecipeCommentUpdate, item_id: UUID = None) -> RecipeCommentOut:
self._check_comment_belongs_to_user()
return self._update_one(data, item_id)
def delete_one(self, item_id: UUID = None) -> RecipeCommentOut:
self._check_comment_belongs_to_user()
return self._delete_one(item_id)

View File

@@ -1,37 +0,0 @@
from __future__ import annotations
from functools import cached_property
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, IngredientFood
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeFoodService(
CrudHttpMixins[IngredientFood, CreateIngredientFood, CreateIngredientFood],
UserHttpService[int, IngredientFood],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = IngredientFood
@cached_property
def repo(self):
return self.db.ingredient_foods
def populate_item(self, id: int) -> IngredientFood:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[IngredientFood]:
return self.repo.get_all()
def create_one(self, data: CreateIngredientFood) -> IngredientFood:
return self._create_one(data)
def update_one(self, data: IngredientFood, item_id: int = None) -> IngredientFood:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> IngredientFood:
return self._delete_one(id)

View File

@@ -1,111 +1,123 @@
import json
import shutil
from functools import cached_property
from pathlib import Path
from shutil import copytree, rmtree
from typing import Union
from zipfile import ZipFile
from fastapi import Depends, HTTPException, UploadFile, status
from sqlalchemy import exc
from fastapi import UploadFile
from mealie.core.dependencies.grouped import UserDeps
from mealie.core.root_logger import get_logger
from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.schema.recipe.recipe import CreateRecipe, Recipe, RecipeSummary
from mealie.core import exceptions
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
from mealie.schema.recipe.recipe_step import RecipeStep
from mealie.schema.user.user import GroupInDB, PrivateUser
from mealie.services._base_service import BaseService
from mealie.services.image.image import write_image
from mealie.services.recipe.mixins import recipe_creation_factory
from .template_service import TemplateService
logger = get_logger(module=__name__)
step_text = """Recipe steps as well as other fields in the recipe page support markdown syntax.
**Add a link**
[My Link](https://beta.mealie.io)
**Imbed an image**
Use the `height="100"` or `width="100"` attributes to set the size of the image.
<img height="100" src="https://images.unsplash.com/photo-1567620905732-2d1ec7ab7445?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=960&q=80"></img>
"""
ingredient_note = "1 Cup Flour"
class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpService[str, Recipe]):
"""
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
"""
class RecipeService(BaseService):
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB):
self.repos = repos
self.user = user
self.group = group
super().__init__()
event_func = create_recipe_event
def _get_recipe(self, slug: str) -> Recipe:
recipe = self.repos.recipes.by_group(self.group.id).get_one(slug)
if recipe is None:
raise exceptions.NoEntryFound("Recipe not found.")
return recipe
@cached_property
def exception_key(self) -> dict:
return {exc.IntegrityError: self.t("recipe.unique-name-error")}
def can_update(self, recipe: Recipe) -> bool:
return recipe.settings.locked is False or self.user.id == recipe.user_id
@cached_property
def repo(self) -> RepositoryRecipes:
return self.db.recipes.by_group(self.group_id)
def can_lock_unlock(self, recipe: Recipe) -> bool:
return recipe.user_id == self.user.id
@classmethod
def write_existing(cls, slug: str, deps: UserDeps = Depends()):
return super().write_existing(slug, deps)
def check_assets(self, recipe: Recipe, original_slug: str) -> None:
"""Checks if the recipe slug has changed, and if so moves the assets to a new file with the new slug."""
if original_slug != recipe.slug:
current_dir = self.directories.RECIPE_DATA_DIR.joinpath(original_slug)
@classmethod
def read_existing(cls, slug: str, deps: UserDeps = Depends()):
return super().write_existing(slug, deps)
try:
copytree(current_dir, recipe.directory, dirs_exist_ok=True)
self.logger.info(f"Renaming Recipe Directory: {original_slug} -> {recipe.slug}")
except FileNotFoundError:
self.logger.error(f"Recipe Directory not Found: {original_slug}")
def assert_existing(self, slug: str):
self.populate_item(slug)
if not self.item:
raise HTTPException(status.HTTP_404_NOT_FOUND)
all_asset_files = [x.file_name for x in recipe.assets]
if not self.item.settings.public and not self.user:
raise HTTPException(status.HTTP_403_FORBIDDEN)
for file in recipe.asset_dir.iterdir():
file: Path
if file.is_dir():
continue
if file.name not in all_asset_files:
file.unlink()
def can_update(self) -> bool:
if self.item.settings.locked and self.user.id != self.item.user_id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
def delete_assets(self, recipe: Recipe) -> None:
recipe_dir = recipe.directory
rmtree(recipe_dir, ignore_errors=True)
self.logger.info(f"Recipe Directory Removed: {recipe.slug}")
return True
@staticmethod
def _recipe_creation_factory(user: PrivateUser, name: str, additional_attrs: dict = None) -> Recipe:
"""
The main creation point for recipes. The factor method returns an instance of the
Recipe Schema class with the appropriate defaults set. Recipes shoudld not be created
else-where to avoid conflicts.
"""
additional_attrs = additional_attrs or {}
additional_attrs["name"] = name
additional_attrs["user_id"] = user.id
additional_attrs["group_id"] = user.group_id
def get_all(self, start=0, limit=None, load_foods=False) -> list[RecipeSummary]:
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit, load_foods=load_foods)
if not additional_attrs.get("recipe_ingredient"):
additional_attrs["recipe_ingredient"] = [RecipeIngredient(note=ingredient_note)]
new_items = []
if not additional_attrs.get("recipe_instructions"):
additional_attrs["recipe_instructions"] = [RecipeStep(text=step_text)]
for item in items:
# Pydantic/FastAPI can't seem to serialize the ingredient field on thier own.
new_item = item.__dict__
if load_foods:
new_item["recipe_ingredient"] = [x.__dict__ for x in item.recipe_ingredient]
new_items.append(new_item)
return [RecipeSummary.construct(**x) for x in new_items]
return Recipe(**additional_attrs)
def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
group = self.db.groups.get(self.group_id, "id")
create_data: Recipe = recipe_creation_factory(
create_data: Recipe = self._recipe_creation_factory(
self.user,
name=create_data.name,
additional_attrs=create_data.dict(),
)
create_data.settings = RecipeSettings(
public=group.preferences.recipe_public,
show_nutrition=group.preferences.recipe_show_nutrition,
show_assets=group.preferences.recipe_show_assets,
landscape_view=group.preferences.recipe_landscape_view,
disable_comments=group.preferences.recipe_disable_comments,
disable_amount=group.preferences.recipe_disable_amount,
public=self.group.preferences.recipe_public,
show_nutrition=self.group.preferences.recipe_show_nutrition,
show_assets=self.group.preferences.recipe_show_assets,
landscape_view=self.group.preferences.recipe_landscape_view,
disable_comments=self.group.preferences.recipe_disable_comments,
disable_amount=self.group.preferences.recipe_disable_amount,
)
self._create_one(create_data, self.t("generic.server-error"), self.exception_key)
self._create_event(
"Recipe Created",
f"'{self.item.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.item.slug}",
)
return self.item
return self.repos.recipes.create(create_data)
def create_from_zip(self, archive: UploadFile, temp_path: Path) -> Recipe:
"""
@@ -127,69 +139,46 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
with myzip.open(file) as myfile:
recipe_image = myfile.read()
self.create_one(Recipe(**recipe_dict))
recipe = self.create_one(Recipe(**recipe_dict))
if self.item:
write_image(self.item.slug, recipe_image, "webp")
if recipe:
write_image(recipe.slug, recipe_image, "webp")
return self.item
return recipe
def update_one(self, update_data: Recipe) -> Recipe:
self.can_update()
def _pre_update_check(self, slug: str, new_data: Recipe) -> Recipe:
recipe = self._get_recipe(slug)
if not self.can_update(recipe):
raise exceptions.PermissionDenied("You do not have permission to edit this recipe.")
if recipe.settings.locked != new_data.settings.locked and not self.can_lock_unlock(recipe):
raise exceptions.PermissionDenied("You do not have permission to lock/unlock this recipe.")
if self.item.settings.locked != update_data.settings.locked and self.item.user_id != self.user.id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
return recipe
original_slug = self.item.slug
self._update_one(update_data, original_slug)
def update_one(self, slug: str, update_data: Recipe) -> Recipe:
recipe = self._pre_update_check(slug, update_data)
new_data = self.repos.recipes.update(slug, update_data)
self.check_assets(new_data, recipe.slug)
return new_data
self.check_assets(original_slug)
return self.item
def patch_one(self, slug: str, patch_data: Recipe) -> Recipe:
recipe = self._pre_update_check(slug, patch_data)
recipe = self.repos.recipes.by_group(self.group.id).get_one(slug)
new_data = self.repos.recipes.patch(recipe.slug, patch_data)
def patch_one(self, patch_data: Recipe) -> Recipe:
self.can_update()
self.check_assets(new_data, recipe.slug)
return new_data
original_slug = self.item.slug
self._patch_one(patch_data, original_slug)
self.check_assets(original_slug)
return self.item
def delete_one(self) -> Recipe:
self.can_update()
self._delete_one(self.item.slug)
self.delete_assets()
self._create_event("Recipe Delete", f"'{self.item.name}' deleted by {self.user.full_name}")
return self.item
def check_assets(self, original_slug: str) -> None:
"""Checks if the recipe slug has changed, and if so moves the assets to a new file with the new slug."""
if original_slug != self.item.slug:
current_dir = self.app_dirs.RECIPE_DATA_DIR.joinpath(original_slug)
try:
copytree(current_dir, self.item.directory, dirs_exist_ok=True)
logger.info(f"Renaming Recipe Directory: {original_slug} -> {self.item.slug}")
except FileNotFoundError:
logger.error(f"Recipe Directory not Found: {original_slug}")
all_asset_files = [x.file_name for x in self.item.assets]
for file in self.item.asset_dir.iterdir():
file: Path
if file.is_dir():
continue
if file.name not in all_asset_files:
file.unlink()
def delete_assets(self) -> None:
recipe_dir = self.item.directory
rmtree(recipe_dir, ignore_errors=True)
logger.info(f"Recipe Directory Removed: {self.item.slug}")
def delete_one(self, slug) -> Recipe:
recipe = self._get_recipe(slug)
self.can_update(recipe)
data = self.repos.recipes.delete(slug)
self.delete_assets(data)
return data
# =================================================================
# Recipe Template Methods
def render_template(self, temp_dir: Path, template: str = None) -> Path:
def render_template(self, recipe: Recipe, temp_dir: Path, template: str = None) -> Path:
t_service = TemplateService(temp_dir)
return t_service.render(self.item, template)
return t_service.render(recipe, template)

View File

@@ -1,37 +0,0 @@
from __future__ import annotations
from functools import cached_property
from mealie.schema.recipe import RecipeTool, RecipeToolCreate
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeToolService(
CrudHttpMixins[RecipeTool, RecipeToolCreate, RecipeToolCreate],
UserHttpService[int, RecipeTool],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = RecipeTool
@cached_property
def repo(self):
return self.db.tools
def populate_item(self, id: int) -> RecipeTool:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[RecipeTool]:
return self.repo.get_all()
def create_one(self, data: RecipeToolCreate) -> RecipeTool:
return self._create_one(data)
def update_one(self, data: RecipeTool, item_id: int = None) -> RecipeTool:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> RecipeTool:
return self._delete_one(id)

View File

@@ -1,37 +0,0 @@
from __future__ import annotations
from functools import cached_property
from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeUnitService(
CrudHttpMixins[IngredientUnit, CreateIngredientUnit, CreateIngredientUnit],
UserHttpService[int, IngredientUnit],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = IngredientUnit
@cached_property
def repo(self):
return self.db.ingredient_units
def populate_item(self, id: int) -> IngredientUnit:
self.item = self.repo.get_one(id)
return self.item
def get_all(self) -> list[IngredientUnit]:
return self.repo.get_all()
def create_one(self, data: CreateIngredientUnit) -> IngredientUnit:
return self._create_one(data)
def update_one(self, data: IngredientUnit, item_id: int = None) -> IngredientUnit:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> IngredientUnit:
return self._delete_one(id)

View File

@@ -32,7 +32,7 @@ class TemplateService(BaseService):
Returns a list of all templates available to render.
"""
return {
TemplateType.jinja2.value: [x.name for x in self.app_dirs.TEMPLATE_DIR.iterdir() if x.is_file()],
TemplateType.jinja2.value: [x.name for x in self.directories.TEMPLATE_DIR.iterdir() if x.is_file()],
TemplateType.json.value: ["raw"],
TemplateType.zip.value: ["zip"],
}
@@ -98,7 +98,7 @@ class TemplateService(BaseService):
"""
self.__check_temp(self._render_jinja2)
j2_template: Path = self.app_dirs.TEMPLATE_DIR / j2_template
j2_template: Path = self.directories.TEMPLATE_DIR / j2_template
if not j2_template.is_file():
raise FileNotFoundError(f"Template '{j2_template}' not found.")