Refactor/group page (#666)

* refactor(backend): ♻️ Refactor base class to be abstract and create a router factory method

* feat(frontend):  add group edit

* refactor(backend):  add group edit support

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden
2021-09-01 21:39:40 -08:00
committed by GitHub
parent 9b1bf56a5d
commit 990244e37e
37 changed files with 749 additions and 196 deletions

View File

@@ -1,2 +1,3 @@
from .base_http_service import *
from .base_service import *
from .router_factory import *

View File

@@ -1,6 +1,7 @@
from abc import ABC, abstractmethod
from typing import Callable, Generic, TypeVar
from fastapi import BackgroundTasks, Depends
from fastapi import BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_dirs, get_settings
@@ -16,13 +17,13 @@ T = TypeVar("T")
D = TypeVar("D")
class BaseHttpService(Generic[T, D]):
class BaseHttpService(Generic[T, D], ABC):
"""The BaseHttpService class is a generic class that can be used to create
http services that are injected via `Depends` into a route function. To use,
you must define the Generic type arguments:
`T`: The type passed into the *_existing functions (e.g. id) which is then passed into assert_existing
`D`: Not yet implemented
`D`: Item returned from database layer
Child Requirements:
Define the following functions:
@@ -32,8 +33,29 @@ class BaseHttpService(Generic[T, D]):
`event_func`: A function that is called when an event is created.
"""
item: D = None
# Function that Generate Corrsesponding Routes through RouterFactor
get_all: Callable = None
create_one: Callable = None
update_one: Callable = None
update_many: Callable = None
populate_item: Callable = None
delete_one: Callable = None
delete_all: Callable = None
# Type Definitions
_schema = None
_create_schema = None
_update_schema = None
# Function called to create a server side event
event_func: Callable = None
# Config
_restrict_by_group = False
_group_id_cache = None
def __init__(self, session: Session, user: PrivateUser, background_tasks: BackgroundTasks = None) -> None:
self.session = session or SessionLocal()
self.user = user
@@ -45,33 +67,32 @@ class BaseHttpService(Generic[T, D]):
self.app_dirs = get_app_dirs()
self.settings = get_settings()
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)
@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")
self._group_id_cache = group.id
return self._group_id_cache
@classmethod
def read_existing(cls, id: T, deps: ReadDeps = Depends()):
def read_existing(cls, item_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.
"""
new_class = cls(deps.session, deps.user, deps.bg_task)
new_class.assert_existing(id)
new_class.assert_existing(item_id)
return new_class
@classmethod
def write_existing(cls, id: T, deps: WriteDeps = Depends()):
def write_existing(cls, item_id: T, deps: WriteDeps = Depends()):
"""
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.
"""
new_class = cls(deps.session, deps.user, deps.bg_task)
new_class.assert_existing(id)
new_class.assert_existing(item_id)
return new_class
@classmethod
@@ -87,3 +108,27 @@ class BaseHttpService(Generic[T, D]):
A Base instance to be used as a router dependency
"""
return cls(deps.session, deps.user, deps.bg_task)
@abstractmethod
def populate_item(self) -> None:
...
def assert_existing(self, id: T) -> None:
self.populate_item(id)
self._check_item()
def _check_item(self) -> None:
if not self.item:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if self.__class__._restrict_by_group:
group_id = getattr(self.item, "group_id", False)
if not group_id or group_id != self.group_id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
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)

View File

@@ -0,0 +1,190 @@
from typing import Any, Callable, Optional, Sequence, Type, TypeVar
from fastapi import APIRouter
from fastapi.params import Depends
from fastapi.types import DecoratedCallable
from pydantic import BaseModel
from .base_http_service import BaseHttpService
""""
This code is largely based off of the FastAPI Crud Router
https://github.com/awtkns/fastapi-crudrouter/blob/master/fastapi_crudrouter/core/_base.py
"""
T = TypeVar("T", bound=BaseModel)
S = TypeVar("S", bound=BaseHttpService)
DEPENDENCIES = Optional[Sequence[Depends]]
class RouterFactory(APIRouter):
schema: Type[T]
create_schema: Type[T]
update_schema: Type[T]
_base_path: str = "/"
def __init__(
self,
service: Type[S],
prefix: Optional[str] = None,
tags: Optional[list[str]] = None,
*args,
**kwargs,
):
self.service: Type[S] = service
self.schema: Type[T] = service._schema
# HACK: Special Case for Coobooks, not sure this is a good way to handle the abstraction :/
if hasattr(self.service, "_get_one_schema"):
self.get_one_schema = self.service._get_one_schema
else:
self.get_one_schema = self.schema
self.update_schema: Type[T] = service._update_schema
self.create_schema: Type[T] = service._create_schema
prefix = str(prefix or self.schema.__name__).lower()
prefix = self._base_path + prefix.strip("/")
tags = tags or [prefix.strip("/").capitalize()]
super().__init__(prefix=prefix, tags=tags, **kwargs)
if self.service.get_all:
self._add_api_route(
"",
self._get_all(),
methods=["GET"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Get All",
)
if self.service.create_one:
self._add_api_route(
"",
self._create(),
methods=["POST"],
response_model=self.schema,
summary="Create One",
)
if self.service.update_many:
self._add_api_route(
"",
self._update_many(),
methods=["PUT"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Update Many",
)
if self.service.delete_all:
self._add_api_route(
"",
self._delete_all(),
methods=["DELETE"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Delete All",
)
if self.service.populate_item:
self._add_api_route(
"/{item_id}",
self._get_one(),
methods=["GET"],
response_model=self.get_one_schema,
summary="Get One",
)
if self.service.update_one:
self._add_api_route(
"/{item_id}",
self._update(),
methods=["PUT"],
response_model=self.schema,
summary="Update One",
)
if self.service.delete_one:
self._add_api_route(
"/{item_id}",
self._delete_one(),
methods=["DELETE"],
response_model=self.schema,
summary="Delete One",
)
def _add_api_route(self, path: str, endpoint: Callable[..., Any], **kwargs: Any) -> None:
dependencies = []
super().add_api_route(path, endpoint, dependencies=dependencies, **kwargs)
def api_route(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
"""Overrides and exiting route if it exists"""
methods = kwargs["methods"] if "methods" in kwargs else ["GET"]
self.remove_api_route(path, methods)
return super().api_route(path, *args, **kwargs)
def get(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["Get"])
return super().get(path, *args, **kwargs)
def post(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["POST"])
return super().post(path, *args, **kwargs)
def put(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["PUT"])
return super().put(path, *args, **kwargs)
def delete(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
self.remove_api_route(path, ["DELETE"])
return super().delete(path, *args, **kwargs)
def remove_api_route(self, path: str, methods: list[str]) -> None:
methods_ = set(methods)
for route in self.routes:
if route.path == f"{self.prefix}{path}" and route.methods == methods_:
self.routes.remove(route)
def _get_all(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(service: S = Depends(self.service.private)) -> T: # type: ignore
return service.get_all()
return route
def _get_one(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(service: S = Depends(self.service.write_existing)) -> T: # type: ignore
return service.item
return route
def _create(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(data: self.create_schema, service: S = Depends(self.service.private)) -> T: # type: ignore
return service.create_one(data)
return route
def _update(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(data: self.update_schema, service: S = Depends(self.service.write_existing)) -> T: # type: ignore
return service.update_one(data)
return route
def _update_many(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(data: list[self.update_schema], service: S = Depends(self.service.write_existing)) -> T: # type: ignore
return service.update_many(data)
return route
def _delete_one(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
def route(service: S = Depends(self.service.write_existing)) -> T: # type: ignore
return service.delete_one()
return route
def _delete_all(self, *args: Any, **kwargs: Any) -> Callable[..., Any]:
raise NotImplementedError
@staticmethod
def get_routes() -> list[str]:
return ["get_all", "create", "delete_all", "get_one", "update", "delete_one"]

View File

@@ -3,55 +3,33 @@ from __future__ import annotations
from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook
from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook
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[int, 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
"""
class CookbookService(BaseHttpService[int, ReadCookBook]):
event_func = create_group_event
cookbook: ReadCookBook # Required for proper type hints
_restrict_by_group = True
_group_id_cache = None
_schema = ReadCookBook
_create_schema = CreateCookBook
_update_schema = UpdateCookBook
_get_one_schema = RecipeCookBook
@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: int | str):
def populate_item(self, id: int | str):
try:
id = int(id)
except Exception:
pass
if isinstance(id, int):
self.cookbook = self.db.cookbooks.get_one(self.session, id, override_schema=RecipeCookBook)
self.item = self.db.cookbooks.get_one(self.session, id, override_schema=RecipeCookBook)
else:
self.cookbook = self.db.cookbooks.get_one(self.session, id, key="slug", override_schema=RecipeCookBook)
self.item = self.db.cookbooks.get_one(self.session, id, key="slug", override_schema=RecipeCookBook)
def get_all(self) -> list[ReadCookBook]:
items = self.db.cookbooks.get(self.session, self.group_id, "group_id", limit=999)
@@ -60,22 +38,22 @@ class CookbookService(BaseHttpService[int, str]):
def create_one(self, data: CreateCookBook) -> ReadCookBook:
try:
self.cookbook = self.db.cookbooks.create(self.session, SaveCookBook(group_id=self.group_id, **data.dict()))
self.item = 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
return self.item
def update_one(self, data: CreateCookBook, id: int = None) -> ReadCookBook:
if not self.cookbook:
if not self.item:
return
target_id = id or self.cookbook.id
self.cookbook = self.db.cookbooks.update(self.session, target_id, data)
target_id = id or self.item.id
self.item = self.db.cookbooks.update(self.session, target_id, data)
return self.cookbook
return self.item
def update_many(self, data: list[ReadCookBook]) -> list[ReadCookBook]:
updated = []
@@ -87,10 +65,10 @@ class CookbookService(BaseHttpService[int, str]):
return updated
def delete_one(self, id: int = None) -> ReadCookBook:
if not self.cookbook:
if not self.item:
return
target_id = id or self.cookbook.id
self.cookbook = self.db.cookbooks.delete(self.session, target_id)
target_id = id or self.item.id
self.item = self.db.cookbooks.delete(self.session, target_id)
return self.cookbook
return self.item

View File

@@ -0,0 +1,2 @@
from .group_service import *
from .webhook_service import *

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from mealie.core.dependencies.grouped import WriteDeps
from mealie.core.root_logger import get_logger
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.schema.user.user import GroupInDB
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 GroupSelfService(BaseHttpService[int, str]):
_restrict_by_group = True
event_func = create_group_event
item: GroupInDB
@classmethod
def read_existing(cls, deps: WriteDeps = Depends()):
"""Override parent method to remove `item_id` from arguments"""
return super().read_existing(item_id=0, deps=deps)
@classmethod
def write_existing(cls, deps: WriteDeps = Depends()):
"""Override parent method to remove `item_id` from arguments"""
return super().write_existing(item_id=0, deps=deps)
def assert_existing(self, _: str = None):
self.populate_item()
if not self.item:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if self.item.id != self.group_id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
def populate_item(self, _: str = None):
self.item = self.db.groups.get(self.session, self.group_id)
def update_categories(self, new_categories: list[CategoryBase]):
if not self.item:
return
self.item.categories = new_categories
return self.db.groups.update(self.session, self.group_id, self.item)

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.schema.group import ReadWebhook
from mealie.schema.group.webhook import CreateWebhook, SaveWebhook
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 WebhookService(BaseHttpService[int, ReadWebhook]):
event_func = create_group_event
_restrict_by_group = True
_schema = ReadWebhook
_create_schema = CreateWebhook
_update_schema = CreateWebhook
def populate_item(self, id: int | str):
self.item = self.db.webhooks.get_one(self.session, id)
def get_all(self) -> list[ReadWebhook]:
return self.db.webhooks.get(self.session, self.group_id, match_key="group_id", limit=9999)
def create_one(self, data: CreateWebhook) -> ReadWebhook:
try:
self.item = self.db.webhooks.create(self.session, SaveWebhook(group_id=self.group_id, **data.dict()))
except Exception as ex:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail={"message": "WEBHOOK_CREATION_ERROR", "exception": str(ex)}
)
return self.item
def update_one(self, data: CreateWebhook, id: int = None) -> ReadWebhook:
if not self.item:
return
target_id = id or self.item.id
self.item = self.db.webhooks.update(self.session, target_id, data)
return self.item
def delete_one(self, id: int = None) -> ReadWebhook:
if not self.item:
return
target_id = id or self.item.id
self.db.webhooks.delete(self.session, target_id)
return self.item

View File

@@ -14,7 +14,7 @@ from mealie.services.events import create_recipe_event
logger = get_logger(module=__name__)
class RecipeService(BaseHttpService[str, str]):
class RecipeService(BaseHttpService[str, Recipe]):
"""
Class Methods:
`read_existing`: Reads an existing recipe from the database.
@@ -23,7 +23,6 @@ class RecipeService(BaseHttpService[str, str]):
"""
event_func = create_recipe_event
recipe: Recipe # Required for proper type hints
@classmethod
def write_existing(cls, slug: str, deps: WriteDeps = Depends()):
@@ -34,17 +33,17 @@ class RecipeService(BaseHttpService[str, str]):
return super().write_existing(slug, deps)
def assert_existing(self, slug: str):
self.pupulate_recipe(slug)
self.populate_item(slug)
if not self.recipe:
if not self.item:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if not self.recipe.settings.public and not self.user:
if not self.item.settings.public and not self.user:
raise HTTPException(status.HTTP_403_FORBIDDEN)
def pupulate_recipe(self, slug: str) -> Recipe:
self.recipe = self.db.recipes.get(self.session, slug)
return self.recipe
def populate_item(self, slug: str) -> Recipe:
self.item = self.db.recipes.get(self.session, slug)
return self.item
# CRUD METHODS
def create_recipe(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
@@ -52,34 +51,34 @@ class RecipeService(BaseHttpService[str, str]):
create_data = Recipe(name=create_data.name)
try:
self.recipe = self.db.recipes.create(self.session, create_data)
self.item = 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}",
f"'{self.item.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.item.slug}",
)
return self.recipe
return self.item
def update_recipe(self, update_data: Recipe) -> Recipe:
original_slug = self.recipe.slug
original_slug = self.item.slug
try:
self.recipe = self.db.recipes.update(self.session, original_slug, update_data)
self.item = self.db.recipes.update(self.session, original_slug, update_data)
except IntegrityError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "RECIPE_ALREADY_EXISTS"})
self._check_assets(original_slug)
return self.recipe
return self.item
def patch_recipe(self, patch_data: Recipe) -> Recipe:
original_slug = self.recipe.slug
original_slug = self.item.slug
try:
self.recipe = self.db.recipes.patch(
self.item = self.db.recipes.patch(
self.session, original_slug, patch_data.dict(exclude_unset=True, exclude_defaults=True)
)
except IntegrityError:
@@ -87,7 +86,7 @@ class RecipeService(BaseHttpService[str, str]):
self._check_assets(original_slug)
return self.recipe
return self.item
def delete_recipe(self) -> Recipe:
"""removes a recipe from the database and purges the existing files from the filesystem.
@@ -100,7 +99,7 @@ class RecipeService(BaseHttpService[str, str]):
"""
try:
recipe: Recipe = self.db.recipes.delete(self.session, self.recipe.slug)
recipe: Recipe = self.db.recipes.delete(self.session, self.item.slug)
self._delete_assets()
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)
@@ -110,18 +109,18 @@ class RecipeService(BaseHttpService[str, str]):
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.recipe.slug:
if original_slug != self.item.slug:
current_dir = self.app_dirs.RECIPE_DATA_DIR.joinpath(original_slug)
try:
copytree(current_dir, self.recipe.directory, dirs_exist_ok=True)
logger.info(f"Renaming Recipe Directory: {original_slug} -> {self.recipe.slug}")
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.recipe.assets]
all_asset_files = [x.file_name for x in self.item.assets]
for file in self.recipe.asset_dir.iterdir():
for file in self.item.asset_dir.iterdir():
file: Path
if file.is_dir():
continue
@@ -129,6 +128,6 @@ class RecipeService(BaseHttpService[str, str]):
file.unlink()
def _delete_assets(self) -> None:
recipe_dir = self.recipe.directory
recipe_dir = self.item.directory
rmtree(recipe_dir, ignore_errors=True)
logger.info(f"Recipe Directory Removed: {self.recipe.slug}")
logger.info(f"Recipe Directory Removed: {self.item.slug}")