diff --git a/mealie/core/config.py b/mealie/core/config.py index bbf57ee3..e5d93a28 100644 --- a/mealie/core/config.py +++ b/mealie/core/config.py @@ -1,5 +1,6 @@ import os import secrets +from functools import lru_cache from pathlib import Path from typing import Any, Optional, Union @@ -175,11 +176,13 @@ class AppSettings(BaseSettings): settings = AppSettings() +@lru_cache def get_app_dirs() -> AppDirectories: global app_dirs return app_dirs +@lru_cache def get_settings() -> AppSettings: global settings return settings diff --git a/mealie/core/dependencies/dependencies.py b/mealie/core/dependencies/dependencies.py index 7b575483..73681eb9 100644 --- a/mealie/core/dependencies/dependencies.py +++ b/mealie/core/dependencies/dependencies.py @@ -9,7 +9,7 @@ from sqlalchemy.orm.session import Session from mealie.core.config import app_dirs, settings from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.schema.user import LongLiveTokenInDB, TokenData, PrivateUser +from mealie.schema.user import LongLiveTokenInDB, PrivateUser, TokenData oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") oauth2_scheme_soft_fail = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False) diff --git a/mealie/db/data_access_layer/db_access.py b/mealie/db/data_access_layer/db_access.py index d88b0e3b..280b0740 100644 --- a/mealie/db/data_access_layer/db_access.py +++ b/mealie/db/data_access_layer/db_access.py @@ -27,7 +27,7 @@ from mealie.schema.recipe import ( RecipeCategoryResponse, RecipeTagResponse, ) -from mealie.schema.user import GroupInDB, LongLiveTokenInDB, SignUpOut, PrivateUser +from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser, SignUpOut from ._base_access_model import BaseAccessModel from .recipe_access_model import RecipeDataAccessModel diff --git a/mealie/db/data_initialization/resources/units/en-us.json b/mealie/db/data_initialization/resources/units/en-us.json index 0f82e192..66d69e08 100644 --- a/mealie/db/data_initialization/resources/units/en-us.json +++ b/mealie/db/data_initialization/resources/units/en-us.json @@ -82,5 +82,41 @@ "description": "", "fraction": true, "abbreviation": "mg" + }, + { + "name": "splash", + "description": "", + "fraction": true, + "abbreviation": "" + }, + { + "name": "dash", + "description": "", + "fraction": true, + "abbreviation": "" + }, + { + "name": "serving", + "description": "", + "fraction": true, + "abbreviation": "" + }, + { + "name": "head", + "description": "", + "fraction": true, + "abbreviation": "" + }, + { + "name": "clove", + "description": "", + "fraction": true, + "abbreviation": "" + }, + { + "name": "can", + "description": "", + "fraction": true, + "abbreviation": "" } ] \ No newline at end of file diff --git a/mealie/routes/groups/crud.py b/mealie/routes/groups/crud.py index c5786ae7..e3d812ab 100644 --- a/mealie/routes/groups/crud.py +++ b/mealie/routes/groups/crud.py @@ -5,7 +5,7 @@ from mealie.core.dependencies import get_current_user from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.routers import AdminAPIRouter, UserAPIRouter -from mealie.schema.user import GroupBase, GroupInDB, UpdateGroup, PrivateUser +from mealie.schema.user import GroupBase, GroupInDB, PrivateUser, UpdateGroup from mealie.services.events import create_group_event admin_router = AdminAPIRouter(prefix="/groups", tags=["Groups: CRUD"]) diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py index 0582781a..7d0657de 100644 --- a/mealie/routes/users/crud.py +++ b/mealie/routes/users/crud.py @@ -8,7 +8,7 @@ from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.routers import AdminAPIRouter, UserAPIRouter from mealie.routes.users._helpers import assert_user_change_allowed -from mealie.schema.user import UserBase, UserIn, PrivateUser, UserOut +from mealie.schema.user import PrivateUser, UserBase, UserIn, UserOut from mealie.services.events import create_user_event user_router = UserAPIRouter(prefix="") diff --git a/mealie/routes/users/favorites.py b/mealie/routes/users/favorites.py index 4c59f0cd..a43d8856 100644 --- a/mealie/routes/users/favorites.py +++ b/mealie/routes/users/favorites.py @@ -6,7 +6,7 @@ from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.routers import UserAPIRouter from mealie.routes.users._helpers import assert_user_change_allowed -from mealie.schema.user import UserFavorites, PrivateUser +from mealie.schema.user import PrivateUser, UserFavorites user_router = UserAPIRouter() diff --git a/mealie/routes/users/sign_up.py b/mealie/routes/users/sign_up.py index 90ed5452..7ce9a191 100644 --- a/mealie/routes/users/sign_up.py +++ b/mealie/routes/users/sign_up.py @@ -8,7 +8,7 @@ from mealie.core.security import hash_password from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.routers import AdminAPIRouter -from mealie.schema.user import SignUpIn, SignUpOut, SignUpToken, UserIn, PrivateUser +from mealie.schema.user import PrivateUser, SignUpIn, SignUpOut, SignUpToken, UserIn from mealie.services.events import create_user_event public_router = APIRouter(prefix="/sign-ups") diff --git a/mealie/services/base_http_service/__init__.py b/mealie/services/base_http_service/__init__.py new file mode 100644 index 00000000..834ba288 --- /dev/null +++ b/mealie/services/base_http_service/__init__.py @@ -0,0 +1,2 @@ +from .base_http_service import * +from .base_service import * diff --git a/mealie/services/base_http_service/base_http_service.py b/mealie/services/base_http_service/base_http_service.py new file mode 100644 index 00000000..61f34512 --- /dev/null +++ b/mealie/services/base_http_service/base_http_service.py @@ -0,0 +1,109 @@ +from typing import Callable, Generic, TypeVar + +from fastapi import BackgroundTasks, Depends +from sqlalchemy.orm.session import Session + +from mealie.core.config import get_app_dirs, get_settings +from mealie.core.dependencies.grouped import ReadDeps, WriteDeps +from mealie.core.root_logger import get_logger +from mealie.db.database import get_database +from mealie.db.db_setup import SessionLocal +from mealie.schema.user.user import PrivateUser + +logger = get_logger() + +T = TypeVar("T") +D = TypeVar("D") + + +class BaseHttpService(Generic[T, D]): + """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 + + Child Requirements: + Define the following functions: + `assert_existing(self, data: T) -> None:` + + Define the following variables: + `event_func`: A function that is called when an event is created. + """ + + event_func: Callable = None + + def __init__(self, session: Session, user: PrivateUser, background_tasks: BackgroundTasks = None) -> None: + self.session = session or SessionLocal() + self.user = user + self.logged_in = bool(self.user) + self.background_tasks = background_tasks + + # Static Globals Dependency Injection + self.db = get_database() + 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") + + @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.assert_existing(id) + return new_class + + @classmethod + def write_existing(cls, 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. + + 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 + + """ + 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) diff --git a/mealie/services/base_http_service/base_service.py b/mealie/services/base_http_service/base_service.py new file mode 100644 index 00000000..6202256c --- /dev/null +++ b/mealie/services/base_http_service/base_service.py @@ -0,0 +1,17 @@ +from mealie.core.config import get_app_dirs, get_settings +from mealie.core.root_logger import get_logger +from mealie.db.database import get_database +from mealie.db.db_setup import generate_session + +logger = get_logger() + + +class BaseService: + def __init__(self) -> None: + # Static Globals Dependency Injection + self.db = get_database() + self.app_dirs = get_app_dirs() + self.settings = get_settings() + + def session_context(self): + return generate_session() diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 8f3a8ae4..fd4da050 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -2,106 +2,35 @@ from pathlib import Path from shutil import copytree, rmtree from typing import Union -from fastapi import BackgroundTasks, Depends, HTTPException, status +from fastapi import Depends, HTTPException, status from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm.session import Session -from mealie.core.config import get_app_dirs, get_settings -from mealie.core.dependencies import ReadDeps from mealie.core.dependencies.grouped import WriteDeps from mealie.core.root_logger import get_logger -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 PrivateUser +from mealie.services.base_http_service.base_http_service import BaseHttpService from mealie.services.events import create_recipe_event logger = get_logger(module=__name__) -class RecipeService: +class RecipeService(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_recipe_event recipe: Recipe # Required for proper type hints - def __init__(self, session: Session, user: PrivateUser, background_tasks: BackgroundTasks = None) -> None: - self.session = session or SessionLocal() - self.user = user - self.background_tasks = background_tasks - self.recipe: Recipe = None - - # Static Globals Dependency Injection - self.db = get_database() - self.app_dirs = get_app_dirs() - self.settings = get_settings() - - @classmethod - def read_existing(cls, slug: str, 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.assert_existing(slug) - return new_class - @classmethod def write_existing(cls, slug: str, 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. - - 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(slug) - return new_class + return super().write_existing(slug, deps) @classmethod - def base(cls, deps: WriteDeps = Depends()) -> Recipe: - """A Base instance to be used as a router dependency - - Raises: - HTTPException: 400 Bad Request - - """ - return cls(deps.session, deps.user, deps.bg_task) - - 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 read_existing(cls, slug: str, deps: WriteDeps = Depends()): + return super().write_existing(slug, deps) def assert_existing(self, slug: str): self.pupulate_recipe(slug) @@ -112,6 +41,10 @@ class RecipeService: if not self.recipe.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 + # CRUD METHODS def create_recipe(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe: if isinstance(create_data, CreateRecipe): @@ -174,9 +107,6 @@ class RecipeService: 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) - 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: diff --git a/mealie/services/user/user_service.py b/mealie/services/user/user_service.py index 9e4251f4..37e28e80 100644 --- a/mealie/services/user/user_service.py +++ b/mealie/services/user/user_service.py @@ -1,37 +1,22 @@ -from fastapi import BackgroundTasks, Depends, HTTPException, status -from sqlalchemy.orm.session import Session +from fastapi import HTTPException, status -from mealie.core.config import get_app_dirs, get_settings -from mealie.core.dependencies import WriteDeps from mealie.core.root_logger import get_logger from mealie.core.security import hash_password, verify_password -from mealie.db.database import get_database -from mealie.db.db_setup import SessionLocal -from mealie.schema.recipe.recipe import Recipe from mealie.schema.user.user import ChangePassword, PrivateUser +from mealie.services.base_http_service.base_http_service import BaseHttpService from mealie.services.events import create_user_event logger = get_logger(module=__name__) -class UserService: - def __init__(self, session: Session, acting_user: PrivateUser, background_tasks: BackgroundTasks = None) -> None: - self.session = session or SessionLocal() - self.acting_user = acting_user - self.background_tasks = background_tasks - self.recipe: Recipe = None +class UserService(BaseHttpService[int, str]): + event_func = create_user_event + acting_user: PrivateUser = None - # Global Singleton Dependency Injection - self.db = get_database() - self.app_dirs = get_app_dirs() - self.settings = get_settings() - - @classmethod - def write_existing(cls, id: int, deps: WriteDeps = Depends()): - new_instance = cls(session=deps.session, acting_user=deps.user, background_tasks=deps.bg_task) - new_instance._populate_target_user(id) - new_instance._assert_user_change_allowed() - return new_instance + def assert_existing(self, id) -> PrivateUser: + self._populate_target_user(id) + self._assert_user_change_allowed() + return self.target_user def _assert_user_change_allowed(self) -> None: if self.acting_user.id != self.target_user.id and not self.acting_user.admin: @@ -46,9 +31,6 @@ class UserService: else: self.target_user = self.acting_user - def _create_event(self, title: str, message: str) -> None: - self.background_tasks.add_task(create_user_event, title, message, self.session) - def change_password(self, password_change: ChangePassword) -> PrivateUser: """""" if not verify_password(password_change.current_password, self.target_user.password):