diff --git a/.gitignore b/.gitignore index 2c1abbc5884f..c43ea0b667b5 100644 --- a/.gitignore +++ b/.gitignore @@ -157,3 +157,4 @@ mealie/services/scraper/ingredient_nlp/model.crfmodel dev/code-generation/generated/openapi.json dev/code-generation/generated/test_routes.py mealie/services/parser_services/crfpp/model.crfmodel +lcov.info diff --git a/.vscode/settings.json b/.vscode/settings.json index cc6bfa5fbab3..68e5e36cd84e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -37,7 +37,7 @@ "python.linting.enabled": true, "python.linting.flake8Enabled": true, "python.linting.pylintEnabled": false, - "python.pythonPath": ".venv/bin/python3.9", + "python.linting.pylintArgs": ["--rcfile=${workspaceFolder}/.pylintrc"], "python.testing.autoTestDiscoverOnSaveEnabled": false, "python.testing.pytestArgs": ["tests"], "python.testing.pytestEnabled": true, @@ -45,5 +45,6 @@ "python.analysis.typeCheckingMode": "off", "search.mode": "reuseEditor", "vetur.validation.template": false, - "python.sortImports.path": "${workspaceFolder}/.venv/bin/isort" + "python.sortImports.path": "${workspaceFolder}/.venv/bin/isort", + "coverage-gutters.lcovname": "${workspaceFolder}/.coverage" } diff --git a/dev/code-generation/_gen_utils.py b/dev/code-generation/_gen_utils.py index 27718c84f795..b51f0dc925fe 100644 --- a/dev/code-generation/_gen_utils.py +++ b/dev/code-generation/_gen_utils.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import re from dataclasses import dataclass from pathlib import Path diff --git a/frontend/pages/user/group/cookbooks.vue b/frontend/pages/user/group/cookbooks.vue index be71fbe34674..3b76a896dea3 100644 --- a/frontend/pages/user/group/cookbooks.vue +++ b/frontend/pages/user/group/cookbooks.vue @@ -51,8 +51,8 @@ event: 'save', }, ]" - @delete="actions.deleteOne(webhook.id)" - @save="actions.updateOne(webhook)" + @delete="actions.deleteOne(cookbook.id)" + @save="actions.updateOne(cookbook)" /> diff --git a/makefile b/makefile index e53adc44d688..1580042eecfd 100644 --- a/makefile +++ b/makefile @@ -59,11 +59,10 @@ lint: ## 🧺 Format, Check and Flake8 poetry run black . poetry run flake8 mealie tests - - coverage: ## ☂️ Check code coverage quickly with the default Python - poetry run pytest + poetry run pytest poetry run coverage report -m + poetry run coveragepy-lcov poetry run coverage html $(BROWSER) htmlcov/index.html diff --git a/mealie/app.py b/mealie/app.py index b04ad9d4e602..a9a317dfc07c 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -9,7 +9,6 @@ from mealie.routes import backup_routes, router, utility_routes from mealie.routes.about import about_router from mealie.routes.handlers import register_debug_handler from mealie.routes.media import media_router -from mealie.routes.site_settings import settings_router from mealie.services.events import create_general_event from mealie.services.scheduler import SchedulerRegistry, SchedulerService, tasks @@ -50,7 +49,6 @@ def api_routers(): app.include_router(router) app.include_router(media_router) app.include_router(about_router) - app.include_router(settings_router) app.include_router(backup_routes.router) app.include_router(utility_routes.router) diff --git a/mealie/core/dependencies/__init__.py b/mealie/core/dependencies/__init__.py index 83e0bad84a33..c9753bd4b95c 100644 --- a/mealie/core/dependencies/__init__.py +++ b/mealie/core/dependencies/__init__.py @@ -1,2 +1 @@ from .dependencies import * -from .grouped import * diff --git a/mealie/core/dependencies/grouped.py b/mealie/core/dependencies/grouped.py deleted file mode 100644 index cbdaacae5699..000000000000 --- a/mealie/core/dependencies/grouped.py +++ /dev/null @@ -1,74 +0,0 @@ -from fastapi import BackgroundTasks, Depends -from sqlalchemy.orm.session import Session - -from mealie.schema.user.user import PrivateUser - -from .dependencies import generate_session, get_admin_user, get_current_user, is_logged_in - - -class RequestContext: - def __init__( - self, - background_tasks: BackgroundTasks, - session: Session = Depends(generate_session), - user=Depends(get_current_user), - ): - self.session: Session = session - self.bg_task: BackgroundTasks = background_tasks - self.user: bool = user - - -class PublicDeps: - """ - PublicDeps contains the common dependencies for all read operations through the API. - Note: The user object is used to definer what assets the user has access to. - - Args: - background_tasks: BackgroundTasks - session: Session - user: bool - """ - - def __init__( - self, - background_tasks: BackgroundTasks, - session: Session = Depends(generate_session), - user=Depends(is_logged_in), - ): - self.session: Session = session - self.bg_task: BackgroundTasks = background_tasks - self.user: bool = user - - -class UserDeps: - """ - UserDeps contains the common dependencies for all read operations through the API. - Note: The user must be logged in or the route will return a 401 error. - - Args: - background_tasks: BackgroundTasks - session: Session - user: UserInDB - """ - - def __init__( - self, - background_tasks: BackgroundTasks, - session: Session = Depends(generate_session), - user=Depends(get_current_user), - ): - self.session: Session = session - self.bg_task: BackgroundTasks = background_tasks - self.user: PrivateUser = user - - -class AdminDeps: - def __init__( - self, - background_tasks: BackgroundTasks, - session: Session = Depends(generate_session), - user=Depends(get_admin_user), - ): - self.session: Session = session - self.bg_task: BackgroundTasks = background_tasks - self.user: PrivateUser = user diff --git a/mealie/core/exceptions.py b/mealie/core/exceptions.py new file mode 100644 index 000000000000..5ce22422a241 --- /dev/null +++ b/mealie/core/exceptions.py @@ -0,0 +1,31 @@ +from sqlite3 import IntegrityError + +from mealie.lang.providers import AbstractLocaleProvider + + +class PermissionDenied(Exception): + """ + This exception is raised when a user tries to access a resource that they do not have permission to access. + """ + + pass + + +class NoEntryFound(Exception): + """ + This exception is raised when a user tries to access a resource that does not exist. + """ + + pass + + +def mealie_registered_exceptions(t: AbstractLocaleProvider) -> dict: + """ + This function returns a dictionary of all the globally registered exceptions in the Mealie application. + """ + + return { + PermissionDenied: t.t("exceptions.permission-denied"), + NoEntryFound: t.t("exceptions.no-entry-found"), + IntegrityError: t.t("exceptions.integrity-error"), + } diff --git a/mealie/core/root_logger.py b/mealie/core/root_logger.py index 41a060afeb67..277572d7f10a 100644 --- a/mealie/core/root_logger.py +++ b/mealie/core/root_logger.py @@ -14,7 +14,6 @@ settings = get_app_settings() LOGGER_FILE = DATA_DIR.joinpath("mealie.log") DATE_FORMAT = "%d-%b-%y %H:%M:%S" LOGGER_FORMAT = "%(levelname)s: %(asctime)s \t%(message)s" -LOGGER_HANDLER = None @dataclass diff --git a/mealie/core/security.py b/mealie/core/security.py index 2b61dd383e09..d6dd611bb0ef 100644 --- a/mealie/core/security.py +++ b/mealie/core/security.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import secrets from datetime import datetime, timedelta from pathlib import Path diff --git a/mealie/db/models/_model_utils/auto_init.py b/mealie/db/models/_model_utils/auto_init.py index 7a64b3ed3d55..2a705384c60f 100644 --- a/mealie/db/models/_model_utils/auto_init.py +++ b/mealie/db/models/_model_utils/auto_init.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from functools import wraps from pydantic import BaseModel, Field diff --git a/mealie/db/models/group/cookbook.py b/mealie/db/models/group/cookbook.py index 4a07d0792df3..ddf51e3e0c76 100644 --- a/mealie/db/models/group/cookbook.py +++ b/mealie/db/models/group/cookbook.py @@ -8,10 +8,12 @@ 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) + position = Column(Integer, nullable=False, default=1) + name = Column(String, nullable=False) - description = Column(String, default="") slug = Column(String, nullable=False) + description = Column(String, default="") + categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True) group_id = Column(guid.GUID, ForeignKey("groups.id")) diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index c9977c3251fb..39ff0ce2f23c 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -88,7 +88,9 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): collection_class=ordering_list("position"), ) - share_tokens = orm.relationship(RecipeShareTokenModel, back_populates="recipe") + share_tokens = orm.relationship( + RecipeShareTokenModel, back_populates="recipe", cascade="all, delete, delete-orphan" + ) comments: list = orm.relationship("RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan") diff --git a/mealie/lang/messages/en-US.json b/mealie/lang/messages/en-US.json index eb731ee2cae1..7272bbb4ce01 100644 --- a/mealie/lang/messages/en-US.json +++ b/mealie/lang/messages/en-US.json @@ -4,5 +4,10 @@ }, "recipe": { "unique-name-error": "Recipe names must be unique" + }, + "exceptions": { + "permission_denied": "You do not have permission to perform this action", + "no-entry-found": "The requested resource was not found", + "integrity-error": "Database integrity error" } } \ No newline at end of file diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py index b83736b7e691..d4972d45ae1b 100644 --- a/mealie/repos/repository_factory.py +++ b/mealie/repos/repository_factory.py @@ -100,6 +100,7 @@ class AllRepositories: @cached_property def categories(self) -> RepositoryCategories: + # TODO: Fix Typing for Category Repository return RepositoryCategories(self.session, pk_slug, Category, RecipeCategoryResponse) @cached_property diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py index 675a1edb0baa..085b5b2cdf49 100644 --- a/mealie/repos/repository_generic.py +++ b/mealie/repos/repository_generic.py @@ -1,18 +1,12 @@ -from __future__ import annotations - from typing import Any, Callable, Generic, TypeVar, Union from uuid import UUID -from pydantic import UUID4 +from pydantic import UUID4, BaseModel from sqlalchemy import func from sqlalchemy.orm import load_only from sqlalchemy.orm.session import Session -from mealie.core.root_logger import get_logger - -logger = get_logger() - -T = TypeVar("T") +T = TypeVar("T", bound=BaseModel) D = TypeVar("D") @@ -40,12 +34,12 @@ class RepositoryGeneric(Generic[T, D]): def subscribe(self, func: Callable) -> None: self.observers.append(func) - def by_user(self, user_id: UUID4) -> RepositoryGeneric: + def by_user(self, user_id: UUID4) -> "RepositoryGeneric[T, D]": self.limit_by_user = True self.user_id = user_id return self - def by_group(self, group_id: UUID) -> RepositoryGeneric: + def by_group(self, group_id: UUID) -> "RepositoryGeneric[T, D]": self.limit_by_group = True self.group_id = group_id return self @@ -147,7 +141,7 @@ class RepositoryGeneric(Generic[T, D]): results_as_dict = [x.dict() for x in results] return [x.get(self.primary_key) for x in results_as_dict] - def _query_one(self, match_value: str, match_key: str = None) -> D: + def _query_one(self, match_value: str | int | UUID4, match_key: str = None) -> D: """ 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. @@ -178,7 +172,7 @@ class RepositoryGeneric(Generic[T, D]): return eff_schema.from_orm(result) def get( - self, match_value: str, match_key: str = None, limit=1, any_case=False, override_schema=None + self, match_value: str | int | UUID4, match_key: str = None, limit=1, any_case=False, override_schema=None ) -> 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. @@ -237,7 +231,7 @@ class RepositoryGeneric(Generic[T, D]): return self.schema.from_orm(new_document) - def update(self, match_value: str, new_data: dict) -> T: + def update(self, match_value: str | int | UUID4, new_data: dict) -> T: """Update a database entry. Args: session (Session): Database Session @@ -258,7 +252,7 @@ class RepositoryGeneric(Generic[T, D]): self.session.commit() return self.schema.from_orm(entry) - def patch(self, match_value: str, new_data: dict) -> T: + def patch(self, match_value: str | int | UUID4, new_data: dict) -> T: new_data = new_data if isinstance(new_data, dict) else new_data.dict() entry = self._query_one(match_value=match_value) diff --git a/mealie/routes/_base/__init__.py b/mealie/routes/_base/__init__.py new file mode 100644 index 000000000000..56926d2fc1f1 --- /dev/null +++ b/mealie/routes/_base/__init__.py @@ -0,0 +1,4 @@ +from .abc_controller import * +from .controller import * +from .dependencies import * +from .mixins import * diff --git a/mealie/routes/_base/abc_controller.py b/mealie/routes/_base/abc_controller.py new file mode 100644 index 000000000000..e0900f766d0c --- /dev/null +++ b/mealie/routes/_base/abc_controller.py @@ -0,0 +1,58 @@ +from abc import ABC +from functools import cached_property + +from fastapi import Depends + +from mealie.repos.all_repositories import AllRepositories +from mealie.routes._base.checks import OperationChecks +from mealie.routes._base.dependencies import SharedDependencies + + +class BasePublicController(ABC): + """ + This is a public class for all User restricted controllers in the API. + It includes the common SharedDependencies and some common methods used + by all Admin controllers. + """ + + deps: SharedDependencies = Depends(SharedDependencies.public) + + +class BaseUserController(ABC): + """ + This is a base class for all User restricted controllers in the API. + It includes the common SharedDependencies and some common methods used + by all Admin controllers. + """ + + deps: SharedDependencies = Depends(SharedDependencies.user) + + @cached_property + def repos(self): + return AllRepositories(self.deps.session) + + @property + def group_id(self): + return self.deps.acting_user.group_id + + @property + def user(self): + return self.deps.acting_user + + @property + def group(self): + return self.deps.repos.groups.get_one(self.group_id) + + @cached_property + def checks(self) -> OperationChecks: + return OperationChecks(self.deps.acting_user) + + +class BaseAdminController(BaseUserController): + """ + This is a base class for all Admin restricted controllers in the API. + It includes the common Shared Dependencies and some common methods used + by all Admin controllers. + """ + + deps: SharedDependencies = Depends(SharedDependencies.admin) diff --git a/mealie/routes/_base/checks.py b/mealie/routes/_base/checks.py new file mode 100644 index 000000000000..a73406cc4d30 --- /dev/null +++ b/mealie/routes/_base/checks.py @@ -0,0 +1,39 @@ +from fastapi import HTTPException, status + +from mealie.schema.user.user import PrivateUser + + +class OperationChecks: + """ + OperationChecks class is a mixin class that can be used on routers to provide common permission + checks and raise the appropriate http error as necessary + """ + + user: PrivateUser + + def __init__(self, user: PrivateUser) -> None: + self.user = user + + def _raise_unauthorized(self) -> None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED) + + def _raise_forbidden(self) -> None: + raise HTTPException(status.HTTP_403_FORBIDDEN) + + # ========================================= + # User Permission Checks + + def can_manage(self) -> bool: + if not self.user.can_manage: + self._raise_forbidden() + return True + + def can_invite(self) -> bool: + if not self.user.can_invite: + self._raise_forbidden() + return True + + def can_organize(self) -> bool: + if not self.user.can_organize: + self._raise_forbidden() + return True diff --git a/mealie/routes/_base/dependencies.py b/mealie/routes/_base/dependencies.py index aa60ac726c8e..1ac3d17fb4c7 100644 --- a/mealie/routes/_base/dependencies.py +++ b/mealie/routes/_base/dependencies.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from functools import cached_property from logging import Logger @@ -17,34 +15,40 @@ from mealie.repos import AllRepositories from mealie.schema.user.user import PrivateUser -def _get_logger() -> Logger: - return get_logger() - - class SharedDependencies: session: Session t: AbstractLocaleProvider - logger: Logger acting_user: PrivateUser | None def __init__(self, session: Session, acting_user: PrivateUser | None) -> None: self.t = get_locale_provider() - self.logger = _get_logger() self.session = session self.acting_user = acting_user + @classmethod + def public(cls, session: Session = Depends(generate_session)) -> "SharedDependencies": + return cls(session, None) + @classmethod def user( - cls, session: Session = Depends(generate_session), user: PrivateUser = Depends(get_current_user) + cls, + session: Session = Depends(generate_session), + user: PrivateUser = Depends(get_current_user), ) -> "SharedDependencies": return cls(session, user) @classmethod def admin( - cls, session: Session = Depends(generate_session), admin: PrivateUser = Depends(get_admin_user) + cls, + session: Session = Depends(generate_session), + admin: PrivateUser = Depends(get_admin_user), ) -> "SharedDependencies": return cls(session, admin) + @cached_property + def logger(self) -> Logger: + return get_logger() + @cached_property def settings(self) -> AppSettings: return get_app_settings() diff --git a/mealie/routes/_base/mixins.py b/mealie/routes/_base/mixins.py index 598f0dbda768..7639ecfbd569 100644 --- a/mealie/routes/_base/mixins.py +++ b/mealie/routes/_base/mixins.py @@ -1,15 +1,30 @@ -from __future__ import annotations - from logging import Logger -from typing import Callable, Type +from typing import Callable, Generic, Type, TypeVar from fastapi import HTTPException, status +from pydantic import UUID4, BaseModel from mealie.repos.repository_generic import RepositoryGeneric from mealie.schema.response import ErrorResponse +C = TypeVar("C", bound=BaseModel) +R = TypeVar("R", bound=BaseModel) +U = TypeVar("U", bound=BaseModel) + + +class CrudMixins(Generic[C, R, U]): + """ + The CrudMixins[C, R, U] class is a mixin class that provides a common set of methods for CRUD operations. + This class is inteded to be used in a composition pattern where a class has a mixin property. For example: + + ``` + class MyClass: + def __init(self repo, logger): + self.mixins = CrudMixins(repo, logger) + ``` + + """ -class CrudMixins: repo: RepositoryGeneric exception_msgs: Callable[[Type[Exception]], str] | None default_message: str = "An unexpected error occurred." @@ -21,17 +36,7 @@ class CrudMixins: exception_msgs: Callable[[Type[Exception]], str] = None, default_message: str = None, ) -> None: - """ - The CrudMixins class is a mixin class that provides a common set of methods for CRUD operations. - This class is inteded to be used in a composition pattern where a class has a mixin property. For example: - ``` - class MyClass: - def __init(self repo, logger): - self.mixins = CrudMixins(repo, logger) - ``` - - """ self.repo = repo self.logger = logger self.exception_msgs = exception_msgs @@ -39,16 +44,6 @@ class CrudMixins: if default_message: self.default_message = default_message - def set_default_message(self, default_msg: str) -> "CrudMixins": - """ - Use this method to set a lookup function for exception messages. When an exception is raised, and - no custom message is set, the default message will be used. - - IMPORTANT! The function returns the same instance of the CrudMixins class, so you can chain calls. - """ - self.default_msg = default_msg - return self - def get_exception_message(self, ext: Exception) -> str: if self.exception_msgs: return self.exception_msgs(type(ext)) @@ -67,8 +62,8 @@ class CrudMixins: detail=ErrorResponse.respond(message=msg, exception=str(ex)), ) - def create_one(self, data): - item = None + def create_one(self, data: C) -> R | None: + item: R | None = None try: item = self.repo.create(data) except Exception as ex: @@ -76,8 +71,8 @@ class CrudMixins: return item - def get_one(self, item_id): - item = self.repo.get(item_id) + def get_one(self, item_id: int | str | UUID4, key: str = None) -> R: + item = self.repo.get_one(item_id, key) if not item: raise HTTPException( @@ -87,33 +82,35 @@ class CrudMixins: return item - def update_one(self, data, item_id): - item = self.repo.get(item_id) + def update_one(self, data: U, item_id: int | str | UUID4) -> R: + item: R = self.repo.get_one(item_id) if not item: - return + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=ErrorResponse.respond(message="Not found."), + ) try: - item = self.repo.update(item.id, data) # type: ignore + item = self.repo.update(item_id, data) # type: ignore except Exception as ex: self.handle_exception(ex) return item - def patch_one(self, data, item_id) -> None: - self.repo.get(item_id) + def patch_one(self, data: U, item_id: int | str | UUID4) -> None: + self.repo.get_one(item_id) try: self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True)) except Exception as ex: self.handle_exception(ex) - def delete_one(self, item_id): - self.logger.info(f"Deleting item with id {item_id}") - + def delete_one(self, item_id: int | str | UUID4) -> R | None: + item: R | None = None try: item = self.repo.delete(item_id) - self.logger.info(item) + self.logger.info(f"Deleting item with id {item_id}") except Exception as ex: self.handle_exception(ex) diff --git a/mealie/routes/routers.py b/mealie/routes/_base/routers.py similarity index 100% rename from mealie/routes/routers.py rename to mealie/routes/_base/routers.py diff --git a/mealie/routes/about/events.py b/mealie/routes/about/events.py index 27d7109aae8c..9f6479b5b648 100644 --- a/mealie/routes/about/events.py +++ b/mealie/routes/about/events.py @@ -1,35 +1,25 @@ -from fastapi import Depends -from sqlalchemy.orm.session import Session - -from mealie.core.root_logger import get_logger -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import AdminAPIRouter +from mealie.routes._base.routers import AdminAPIRouter from mealie.schema.events import EventsOut +from .._base import BaseAdminController, controller + router = AdminAPIRouter(prefix="/events") -logger = get_logger() +@controller(router) +class EventsController(BaseAdminController): + @router.get("", response_model=EventsOut) + async def get_events(self): + """Get event from the Database""" + return EventsOut(total=self.repos.events.count_all(), events=self.repos.events.get_all(order_by="time_stamp")) -@router.get("", response_model=EventsOut) -async def get_events(session: Session = Depends(generate_session)): - """Get event from the Database""" - db = get_repositories(session) + @router.delete("") + async def delete_events(self): + """Get event from the Database""" + self.repos.events.delete_all() + return {"message": "All events deleted"} - return EventsOut(total=db.events.count_all(), events=db.events.get_all(order_by="time_stamp")) - - -@router.delete("") -async def delete_events(session: Session = Depends(generate_session)): - """Get event from the Database""" - db = get_repositories(session) - db.events.delete_all() - return {"message": "All events deleted"} - - -@router.delete("/{id}") -async def delete_event(id: int, session: Session = Depends(generate_session)): - """Delete event from the Database""" - db = get_repositories(session) - return db.events.delete(id) + @router.delete("/{item_id}") + async def delete_event(self, item_id: int): + """Delete event from the Database""" + return self.repos.events.delete(item_id) diff --git a/mealie/routes/admin/__init__.py b/mealie/routes/admin/__init__.py index a213fd1310d1..5c1c8ae7c548 100644 --- a/mealie/routes/admin/__init__.py +++ b/mealie/routes/admin/__init__.py @@ -1,15 +1,12 @@ -from mealie.routes.routers import AdminAPIRouter -from mealie.services._base_http_service.router_factory import RouterFactory -from mealie.services.admin.admin_group_service import AdminGroupService -from mealie.services.admin.admin_user_service import AdminUserService +from mealie.routes._base.routers import AdminAPIRouter -from . import admin_about, admin_email, admin_log, admin_server_tasks +from . import admin_about, admin_email, admin_log, admin_management_groups, admin_management_users, admin_server_tasks router = AdminAPIRouter(prefix="/admin") router.include_router(admin_about.router, tags=["Admin: About"]) router.include_router(admin_log.router, tags=["Admin: Log"]) -router.include_router(RouterFactory(AdminUserService, prefix="/users", tags=["Admin: Users"])) -router.include_router(RouterFactory(AdminGroupService, prefix="/groups", tags=["Admin: Groups"])) +router.include_router(admin_management_users.router) +router.include_router(admin_management_groups.router) router.include_router(admin_email.router, tags=["Admin: Email"]) router.include_router(admin_server_tasks.router, tags=["Admin: Server Tasks"]) diff --git a/mealie/routes/admin/admin_about.py b/mealie/routes/admin/admin_about.py index 245f58bffa0a..39027b2d6705 100644 --- a/mealie/routes/admin/admin_about.py +++ b/mealie/routes/admin/admin_about.py @@ -1,55 +1,51 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.orm.session import Session +from fastapi import APIRouter -from mealie.core.config import get_app_settings from mealie.core.release_checker import get_latest_version from mealie.core.settings.static import APP_VERSION -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories +from mealie.routes._base import BaseAdminController, controller from mealie.schema.admin.about import AdminAboutInfo, AppStatistics, CheckAppConfig router = APIRouter(prefix="/about") -@router.get("", response_model=AdminAboutInfo) -async def get_app_info(): - """Get general application information""" - settings = get_app_settings() +@controller(router) +class AdminAboutController(BaseAdminController): + @router.get("", response_model=AdminAboutInfo) + async def get_app_info(self): + """Get general application information""" + settings = self.deps.settings - return AdminAboutInfo( - production=settings.PRODUCTION, - version=APP_VERSION, - versionLatest=get_latest_version(), - demo_status=settings.IS_DEMO, - api_port=settings.API_PORT, - api_docs=settings.API_DOCS, - db_type=settings.DB_ENGINE, - db_url=settings.DB_URL_PUBLIC, - default_group=settings.DEFAULT_GROUP, - ) + return AdminAboutInfo( + production=settings.PRODUCTION, + version=APP_VERSION, + versionLatest=get_latest_version(), + demo_status=settings.IS_DEMO, + api_port=settings.API_PORT, + api_docs=settings.API_DOCS, + db_type=settings.DB_ENGINE, + db_url=settings.DB_URL_PUBLIC, + default_group=settings.DEFAULT_GROUP, + ) + @router.get("/statistics", response_model=AppStatistics) + async def get_app_statistics(self): -@router.get("/statistics", response_model=AppStatistics) -async def get_app_statistics(session: Session = Depends(generate_session)): - db = get_repositories(session) - return AppStatistics( - total_recipes=db.recipes.count_all(), - uncategorized_recipes=db.recipes.count_uncategorized(), - untagged_recipes=db.recipes.count_untagged(), - total_users=db.users.count_all(), - total_groups=db.groups.count_all(), - ) + return AppStatistics( + total_recipes=self.repos.recipes.count_all(), + uncategorized_recipes=self.repos.recipes.count_uncategorized(), + untagged_recipes=self.repos.recipes.count_untagged(), + total_users=self.repos.users.count_all(), + total_groups=self.repos.groups.count_all(), + ) + @router.get("/check", response_model=CheckAppConfig) + async def check_app_config(self): + settings = self.deps.settings + url_set = settings.BASE_URL != "http://localhost:8080" -@router.get("/check", response_model=CheckAppConfig) -async def check_app_config(): - settings = get_app_settings() - - url_set = settings.BASE_URL != "http://localhost:8080" - - return CheckAppConfig( - email_ready=settings.SMTP_ENABLE, - ldap_ready=settings.LDAP_ENABLED, - base_url_set=url_set, - is_up_to_date=get_latest_version() == APP_VERSION, - ) + return CheckAppConfig( + email_ready=settings.SMTP_ENABLE, + ldap_ready=settings.LDAP_ENABLED, + base_url_set=url_set, + is_up_to_date=get_latest_version() == APP_VERSION, + ) diff --git a/mealie/routes/admin/admin_email.py b/mealie/routes/admin/admin_email.py index 7c3dd30d0971..1c2678cbee47 100644 --- a/mealie/routes/admin/admin_email.py +++ b/mealie/routes/admin/admin_email.py @@ -1,12 +1,9 @@ from fastapi import APIRouter from fastapi_camelcase import CamelModel -from mealie.core.config import get_app_settings -from mealie.core.root_logger import get_logger +from mealie.routes._base import BaseAdminController, controller from mealie.services.email import EmailService -logger = get_logger(__name__) - router = APIRouter(prefix="/email") @@ -23,24 +20,23 @@ class EmailTest(CamelModel): email: str -@router.get("", response_model=EmailReady) -async def check_email_config(): - """Get general application information""" - settings = get_app_settings() +@controller(router) +class AdminEmailController(BaseAdminController): + @router.get("", response_model=EmailReady) + async def check_email_config(self): + """Get general application information""" + return EmailReady(ready=self.deps.settings.SMTP_ENABLE) - return EmailReady(ready=settings.SMTP_ENABLE) + @router.post("", response_model=EmailSuccess) + async def send_test_email(self, data: EmailTest): + service = EmailService() + status = False + error = None + try: + status = service.send_test_email(data.email) + except Exception as e: + self.deps.logger.error(e) + error = str(e) -@router.post("", response_model=EmailSuccess) -async def send_test_email(data: EmailTest): - service = EmailService() - status = False - error = None - - try: - status = service.send_test_email(data.email) - except Exception as e: - logger.error(e) - error = str(e) - - return EmailSuccess(success=status, error=error) + return EmailSuccess(success=status, error=error) diff --git a/mealie/routes/admin/admin_management_groups.py b/mealie/routes/admin/admin_management_groups.py new file mode 100644 index 000000000000..899296801e0f --- /dev/null +++ b/mealie/routes/admin/admin_management_groups.py @@ -0,0 +1,88 @@ +from functools import cached_property +from typing import Type + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import UUID4 + +from mealie.core.exceptions import mealie_registered_exceptions +from mealie.schema.group.group import GroupAdminUpdate +from mealie.schema.mapper import mapper +from mealie.schema.query import GetAll +from mealie.schema.response.responses import ErrorResponse +from mealie.schema.user.user import GroupBase, GroupInDB + +from .._base import BaseAdminController, controller +from .._base.dependencies import SharedDependencies +from .._base.mixins import CrudMixins + +router = APIRouter(prefix="/groups", tags=["Admin: Groups"]) + + +@controller(router) +class AdminUserManagementRoutes(BaseAdminController): + deps: SharedDependencies = Depends(SharedDependencies.user) + + @cached_property + def repo(self): + if not self.deps.acting_user: + raise Exception("No user is logged in.") + + return self.deps.repos.groups + + def registered_exceptions(self, ex: Type[Exception]) -> str: + + registered = { + **mealie_registered_exceptions(self.deps.t), + } + + return registered.get(ex, "An unexpected error occurred.") + + # ======================================================================= + # CRUD Operations + + @property + def mixins(self): + return CrudMixins[GroupBase, GroupInDB, GroupAdminUpdate]( + self.repo, + self.deps.logger, + self.registered_exceptions, + ) + + @router.get("", response_model=list[GroupInDB]) + def get_all(self, q: GetAll = Depends(GetAll)): + return self.repo.get_all(start=q.start, limit=q.limit, override_schema=GroupInDB) + + @router.post("", response_model=GroupInDB, status_code=status.HTTP_201_CREATED) + def create_one(self, data: GroupBase): + return self.mixins.create_one(data) + + @router.get("/{item_id}", response_model=GroupInDB) + def get_one(self, item_id: UUID4): + return self.mixins.get_one(item_id) + + @router.put("/{item_id}", response_model=GroupInDB) + def update_one(self, item_id: UUID4, data: GroupAdminUpdate): + group = self.repo.get_one(item_id) + + if data.preferences: + preferences = self.repos.group_preferences.get_one(value=item_id, key="group_id") + preferences = mapper(data.preferences, preferences) + group.preferences = self.repos.group_preferences.update(item_id, preferences) + + if data.name not in ["", group.name]: + group.name = data.name + group = self.repo.update(item_id, group) + + return group + + @router.delete("/{item_id}", response_model=GroupInDB) + def delete_one(self, item_id: UUID4): + item = self.repo.get_one(item_id) + + if len(item.users) > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorResponse.respond(message="Cannot delete group with users"), + ) + + return self.mixins.delete_one(item_id) diff --git a/mealie/routes/admin/admin_management_users.py b/mealie/routes/admin/admin_management_users.py new file mode 100644 index 000000000000..443fac6bd732 --- /dev/null +++ b/mealie/routes/admin/admin_management_users.py @@ -0,0 +1,61 @@ +from functools import cached_property +from typing import Type + +from fastapi import APIRouter, Depends +from pydantic import UUID4 + +from mealie.core.exceptions import mealie_registered_exceptions +from mealie.routes._base import BaseAdminController, controller +from mealie.routes._base.dependencies import SharedDependencies +from mealie.routes._base.mixins import CrudMixins +from mealie.schema.query import GetAll +from mealie.schema.user.user import UserIn, UserOut + +router = APIRouter(prefix="/users", tags=["Admin: Users"]) + + +@controller(router) +class AdminUserManagementRoutes(BaseAdminController): + deps: SharedDependencies = Depends(SharedDependencies.user) + + @cached_property + def repo(self): + if not self.deps.acting_user: + raise Exception("No user is logged in.") + + return self.deps.repos.users + + def registered_exceptions(self, ex: Type[Exception]) -> str: + + registered = { + **mealie_registered_exceptions(self.deps.t), + } + + return registered.get(ex, "An unexpected error occurred.") + + # ======================================================================= + # CRUD Operations + + @property + def mixins(self): + return CrudMixins[UserIn, UserOut, UserOut](self.repo, self.deps.logger, self.registered_exceptions) + + @router.get("", response_model=list[UserOut]) + def get_all(self, q: GetAll = Depends(GetAll)): + return self.repo.get_all(start=q.start, limit=q.limit, override_schema=UserOut) + + @router.post("", response_model=UserOut) + def create_one(self, data: UserIn): + return self.mixins.create_one(data) + + @router.get("/{item_id}", response_model=UserOut) + def get_one(self, item_id: UUID4): + return self.mixins.get_one(item_id) + + @router.put("/{item_id}", response_model=UserOut) + def update_one(self, item_id: UUID4, data: UserOut): + return self.mixins.update_one(data, item_id) + + @router.delete("/{item_id}", response_model=UserOut) + def delete_one(self, item_id: UUID4): + return self.mixins.delete_one(item_id) diff --git a/mealie/routes/admin/admin_server_tasks.py b/mealie/routes/admin/admin_server_tasks.py index 8584181150a2..e8a8bc15edec 100644 --- a/mealie/routes/admin/admin_server_tasks.py +++ b/mealie/routes/admin/admin_server_tasks.py @@ -1,18 +1,20 @@ -from fastapi import Depends +from fastapi import BackgroundTasks -from mealie.routes.routers import UserAPIRouter +from mealie.routes._base import BaseAdminController, controller +from mealie.routes._base.routers import UserAPIRouter from mealie.schema.server.tasks import ServerTask, ServerTaskNames from mealie.services.server_tasks import BackgroundExecutor, test_executor_func -from mealie.services.server_tasks.tasks_http_service import AdminServerTasks router = UserAPIRouter() -@router.get("/server-tasks", response_model=list[ServerTask]) -def get_all_tasks(tasks_service: AdminServerTasks = Depends(AdminServerTasks.private)): - return tasks_service.get_all() +@controller(router) +class AdminServerTasksController(BaseAdminController): + @router.get("/server-tasks", response_model=list[ServerTask]) + def get_all(self): + return self.repos.server_tasks.get_all(order_by="created_at") - -@router.post("/server-tasks", response_model=ServerTask) -def create_test_tasks(bg_executor: BackgroundExecutor = Depends(BackgroundExecutor.private)): - return bg_executor.dispatch(ServerTaskNames.default, test_executor_func) + @router.post("/server-tasks", response_model=ServerTask, status_code=201) + def create_test_tasks(self, bg_tasks: BackgroundTasks): + bg_executor = BackgroundExecutor(self.group.id, self.repos, bg_tasks) + return bg_executor.dispatch(ServerTaskNames.default, test_executor_func) diff --git a/mealie/routes/auth/auth.py b/mealie/routes/auth/auth.py index d968ff3c510f..eda372e01bfe 100644 --- a/mealie/routes/auth/auth.py +++ b/mealie/routes/auth/auth.py @@ -4,13 +4,14 @@ from typing import Optional from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, status from fastapi.exceptions import HTTPException from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel from sqlalchemy.orm.session import Session from mealie.core import security from mealie.core.dependencies import get_current_user from mealie.core.security import authenticate_user from mealie.db.db_setup import generate_session -from mealie.routes.routers import UserAPIRouter +from mealie.routes._base.routers import UserAPIRouter from mealie.schema.user import PrivateUser from mealie.services.events import create_user_event @@ -38,6 +39,15 @@ class CustomOAuth2Form(OAuth2PasswordRequestForm): self.client_secret = client_secret +class MealieAuthToken(BaseModel): + access_token: str + token_type: str = "bearer" + + @classmethod + def respond(cls, token: str, token_type: str = "bearer") -> dict: + return cls(access_token=token, token_type=token_type).dict() + + @public_router.post("/token") def get_token( background_tasks: BackgroundTasks, @@ -61,11 +71,11 @@ def get_token( duration = timedelta(days=14) if data.remember_me else None access_token = security.create_access_token(dict(sub=user.email), duration) - return {"access_token": access_token, "token_type": "bearer"} + return MealieAuthToken.respond(access_token) @user_router.get("/refresh") async def refresh_token(current_user: PrivateUser = Depends(get_current_user)): """Use a valid token to get another token""" access_token = security.create_access_token(data=dict(sub=current_user.email)) - return {"access_token": access_token, "token_type": "bearer"} + return MealieAuthToken.respond(access_token) diff --git a/mealie/routes/backup_routes.py b/mealie/routes/backup_routes.py index d12e7a26659b..0667f34ba838 100644 --- a/mealie/routes/backup_routes.py +++ b/mealie/routes/backup_routes.py @@ -10,7 +10,7 @@ from mealie.core.dependencies import get_current_user from mealie.core.root_logger import get_logger from mealie.core.security import create_file_token from mealie.db.db_setup import generate_session -from mealie.routes.routers import AdminAPIRouter +from mealie.routes._base.routers import AdminAPIRouter from mealie.schema.admin import AllBackups, BackupFile, CreateBackup, ImportJob from mealie.schema.user.user import PrivateUser from mealie.services.backups import imports diff --git a/mealie/routes/categories/__init__.py b/mealie/routes/categories/__init__.py index 77dcc162e056..523fb16bffca 100644 --- a/mealie/routes/categories/__init__.py +++ b/mealie/routes/categories/__init__.py @@ -2,10 +2,5 @@ from fastapi import APIRouter from . import categories -prefix = "/categories" - router = APIRouter() - -router.include_router(categories.public_router, prefix=prefix, tags=["Categories: CRUD"]) -router.include_router(categories.user_router, prefix=prefix, tags=["Categories: CRUD"]) -router.include_router(categories.admin_router, prefix=prefix, tags=["Categories: CRUD"]) +router.include_router(categories.router) diff --git a/mealie/routes/categories/categories.py b/mealie/routes/categories/categories.py index 8c581edb1852..72c4087bbf67 100644 --- a/mealie/routes/categories/categories.py +++ b/mealie/routes/categories/categories.py @@ -1,82 +1,68 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm.session import Session +from functools import cached_property -from mealie.core.dependencies import is_logged_in -from mealie.core.root_logger import get_logger -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import AdminAPIRouter, UserAPIRouter +from fastapi import APIRouter +from pydantic import BaseModel + +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.mixins import CrudMixins from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse +from mealie.schema.recipe.recipe_category import CategoryBase -public_router = APIRouter() -user_router = UserAPIRouter() -admin_router = AdminAPIRouter() -logger = get_logger() +router = APIRouter(prefix="/categories", tags=["Categories: CRUD"]) -@public_router.get("") -async def get_all_recipe_categories(session: Session = Depends(generate_session)): - """Returns a list of available categories in the database""" - db = get_repositories(session) - return db.categories.get_all_limit_columns(fields=["slug", "name"]) +class CategorySummary(BaseModel): + slug: str + name: str + + class Config: + orm_mode = True -@public_router.get("/empty") -def get_empty_categories(session: Session = Depends(generate_session)): - """Returns a list of categories that do not contain any recipes""" - db = get_repositories(session) - return db.categories.get_empty() +@controller(router) +class RecipeCategoryController(BaseUserController): + # ========================================================================= + # CRUD Operations + @cached_property + def mixins(self): + return CrudMixins(self.repos.categories, self.deps.logger) -@public_router.get("/{category}", response_model=RecipeCategoryResponse) -def get_all_recipes_by_category( - category: str, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in) -): - """Returns a list of recipes associated with the provided category.""" - db = get_repositories(session) + @router.get("", response_model=list[CategorySummary]) + def get_all(self): + """Returns a list of available categories in the database""" + return self.repos.categories.get_all_limit_columns(fields=["slug", "name"]) - category_obj = db.categories.get(category) - category_obj = RecipeCategoryResponse.from_orm(category_obj) + @router.post("", status_code=201) + def create_one(self, category: CategoryIn): + """Creates a Category in the database""" + return self.mixins.create_one(category) - if not is_user: - category_obj.recipes = [x for x in category_obj.recipes if x.settings.public] + @router.get("/{slug}", response_model=RecipeCategoryResponse) + def get_all_recipes_by_category(self, slug: str): + """Returns a list of recipes associated with the provided category.""" + category_obj = self.repos.categories.get(slug) + category_obj = RecipeCategoryResponse.from_orm(category_obj) + return category_obj - return category_obj + @router.put("/{slug}", response_model=RecipeCategoryResponse) + def update_one(self, slug: str, update_data: CategoryIn): + """Updates an existing Tag in the database""" + return self.mixins.update_one(update_data, slug) + @router.delete("/{slug}") + def delete_one(self, slug: str): + """ + Removes a recipe category from the database. Deleting a + category does not impact a recipe. The category will be removed + from any recipes that contain it + """ + self.mixins.delete_one(slug) -@user_router.post("") -async def create_recipe_category(category: CategoryIn, session: Session = Depends(generate_session)): - """Creates a Category in the database""" - db = get_repositories(session) + # ========================================================================= + # Read All Operations - try: - return db.categories.create(category.dict()) - except Exception: - raise HTTPException(status.HTTP_400_BAD_REQUEST) - - -@admin_router.put("/{category}", response_model=RecipeCategoryResponse) -async def update_recipe_category(category: str, new_category: CategoryIn, session: Session = Depends(generate_session)): - """Updates an existing Tag in the database""" - db = get_repositories(session) - - try: - return db.categories.update(category, new_category.dict()) - except Exception: - logger.exception("Failed to update category") - raise HTTPException(status.HTTP_400_BAD_REQUEST) - - -@admin_router.delete("/{category}") -async def delete_recipe_category(category: str, session: Session = Depends(generate_session)): - """ - Removes a recipe category from the database. Deleting a - category does not impact a recipe. The category will be removed - from any recipes that contain it - """ - db = get_repositories(session) - - try: - db.categories.delete(category) - except Exception: - raise HTTPException(status.HTTP_400_BAD_REQUEST) + @router.get("/empty", response_model=list[CategoryBase]) + def get_all_empty(self): + """Returns a list of categories that do not contain any recipes""" + return self.repos.categories.get_empty() diff --git a/mealie/routes/comments/__init__.py b/mealie/routes/comments/__init__.py index 905ff7fc987e..12374be085e6 100644 --- a/mealie/routes/comments/__init__.py +++ b/mealie/routes/comments/__init__.py @@ -1,8 +1,74 @@ -from fastapi import APIRouter +from functools import cached_property +from typing import Type -from mealie.services._base_http_service.router_factory import RouterFactory -from mealie.services.recipe.recipe_comments_service import RecipeCommentsService +from fastapi import APIRouter, Depends, HTTPException +from pydantic import UUID4 -router = APIRouter() +from mealie.core.exceptions import mealie_registered_exceptions +from mealie.routes._base.abc_controller import BaseUserController +from mealie.routes._base.controller import controller +from mealie.routes._base.mixins import CrudMixins +from mealie.schema.query import GetAll +from mealie.schema.recipe.recipe_comments import ( + RecipeCommentCreate, + RecipeCommentOut, + RecipeCommentSave, + RecipeCommentUpdate, +) +from mealie.schema.response.responses import ErrorResponse, SuccessResponse -router.include_router(RouterFactory(RecipeCommentsService, prefix="/comments", tags=["Recipe: Comments"])) +router = APIRouter(prefix="/comments", tags=["Recipe: Comments"]) + + +@controller(router) +class RecipeCommentRoutes(BaseUserController): + @cached_property + def repo(self): + return self.deps.repos.comments + + def registered_exceptions(self, ex: Type[Exception]) -> str: + + registered = { + **mealie_registered_exceptions(self.deps.t), + } + + return registered.get(ex, "An unexpected error occurred.") + + # ======================================================================= + # CRUD Operations + + @property + def mixins(self) -> CrudMixins: + return CrudMixins(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") + + def _check_comment_belongs_to_user(self, item_id: UUID4) -> None: + comment = self.repo.get_one(item_id) + if comment.user_id != self.deps.acting_user.id and not self.deps.acting_user.admin: + raise HTTPException( + status_code=403, + detail=ErrorResponse.response(message="Comment does not belong to user"), + ) + + @router.get("", response_model=list[RecipeCommentOut]) + def get_all(self, q: GetAll = Depends(GetAll)): + return self.repo.get_all(start=q.start, limit=q.limit, override_schema=RecipeCommentOut) + + @router.post("", response_model=RecipeCommentOut, status_code=201) + def create_one(self, data: RecipeCommentCreate): + save_data = RecipeCommentSave(text=data.text, user_id=self.deps.acting_user.id, recipe_id=data.recipe_id) + return self.mixins.create_one(save_data) + + @router.get("/{item_id}", response_model=RecipeCommentOut) + def get_one(self, item_id: UUID4): + return self.mixins.get_one(item_id) + + @router.put("/{item_id}", response_model=RecipeCommentOut) + def update_one(self, item_id: UUID4, data: RecipeCommentUpdate): + self._check_comment_belongs_to_user(item_id) + return self.mixins.update_one(data, item_id) + + @router.delete("/{item_id}", response_model=SuccessResponse) + def delete_one(self, item_id: UUID4): + self._check_comment_belongs_to_user(item_id) + self.mixins.delete_one(item_id) + return SuccessResponse.respond(message="Comment deleted") diff --git a/mealie/routes/groups/__init__.py b/mealie/routes/groups/__init__.py index 9d5b0437b0fe..cab9d78a24b1 100644 --- a/mealie/routes/groups/__init__.py +++ b/mealie/routes/groups/__init__.py @@ -1,59 +1,29 @@ -from datetime import date, timedelta +from fastapi import APIRouter -from fastapi import APIRouter, Depends - -from mealie.schema.reports.reports import ReportCategory -from mealie.services._base_http_service import RouterFactory -from mealie.services.group_services import CookbookService, WebhookService -from mealie.services.group_services.meal_service import MealService -from mealie.services.group_services.reports_service import GroupReportService - -from . import categories, invitations, labels, migrations, notifications, preferences, self_service, shopping_lists +from . import ( + controller_cookbooks, + controller_group_notifications, + controller_group_reports, + controller_group_self_service, + controller_invitations, + controller_labels, + controller_mealplan, + controller_meaplan_config, + controller_migrations, + controller_shopping_lists, + controller_webhooks, +) router = APIRouter() -router.include_router(self_service.user_router) - - -webhook_router = RouterFactory(service=WebhookService, prefix="/groups/webhooks", tags=["Groups: Webhooks"]) -cookbook_router = RouterFactory(service=CookbookService, prefix="/groups/cookbooks", tags=["Groups: Cookbooks"]) - - -@router.get("/groups/mealplans/today", tags=["Groups: Mealplans"]) -def get_todays_meals(ms: MealService = Depends(MealService.private)): - return ms.get_today() - - -meal_plan_router = RouterFactory(service=MealService, prefix="/groups/mealplans", tags=["Groups: Mealplans"]) - - -@meal_plan_router.get("") -def get_all(start: date = None, limit: date = None, ms: MealService = Depends(MealService.private)): - start = start or date.today() - timedelta(days=999) - limit = limit or date.today() + timedelta(days=999) - return ms.get_slice(start, limit) - - -router.include_router(cookbook_router) -router.include_router(meal_plan_router) -router.include_router(categories.user_router) -router.include_router(webhook_router) -router.include_router(invitations.router, prefix="/groups/invitations", tags=["Groups: Invitations"]) -router.include_router(preferences.router, prefix="/groups/preferences", tags=["Group: Preferences"]) -router.include_router(migrations.router, prefix="/groups/migrations", tags=["Group: Migrations"]) - -report_router = RouterFactory(service=GroupReportService, prefix="/groups/reports", tags=["Groups: Reports"]) - - -@report_router.get("") -def get_all_reports( - report_type: ReportCategory = None, - gs: GroupReportService = Depends(GroupReportService.private), -): - return gs._get_all(report_type) - - -router.include_router(report_router) -router.include_router(shopping_lists.router) -router.include_router(labels.router) -router.include_router(notifications.router) +router.include_router(controller_group_self_service.router) +router.include_router(controller_mealplan.router) +router.include_router(controller_cookbooks.router) +router.include_router(controller_meaplan_config.router) +router.include_router(controller_webhooks.router) +router.include_router(controller_invitations.router) +router.include_router(controller_migrations.router) +router.include_router(controller_group_reports.router) +router.include_router(controller_shopping_lists.router) +router.include_router(controller_labels.router) +router.include_router(controller_group_notifications.router) diff --git a/mealie/routes/groups/categories.py b/mealie/routes/groups/categories.py deleted file mode 100644 index 0405a3bd9f71..000000000000 --- a/mealie/routes/groups/categories.py +++ /dev/null @@ -1,22 +0,0 @@ -from fastapi import Depends - -from mealie.routes.routers import UserAPIRouter -from mealie.schema.recipe.recipe_category import CategoryBase -from mealie.services.group_services.group_service import GroupSelfService - -user_router = UserAPIRouter(prefix="/groups/categories", tags=["Groups: Mealplan Categories"]) - - -@user_router.get("", response_model=list[CategoryBase]) -def get_mealplan_categories(group_service: GroupSelfService = Depends(GroupSelfService.read_existing)): - return group_service.item.categories - - -@user_router.put("", response_model=list[CategoryBase]) -def update_mealplan_categories( - new_categories: list[CategoryBase], group_service: GroupSelfService = Depends(GroupSelfService.write_existing) -): - - items = group_service.update_categories(new_categories) - - return items.categories diff --git a/mealie/routes/groups/controller_cookbooks.py b/mealie/routes/groups/controller_cookbooks.py new file mode 100644 index 000000000000..e06587fff4d5 --- /dev/null +++ b/mealie/routes/groups/controller_cookbooks.py @@ -0,0 +1,70 @@ +from functools import cached_property +from typing import Type + +from fastapi import APIRouter + +from mealie.core.exceptions import mealie_registered_exceptions +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.mixins import CrudMixins +from mealie.schema import mapper +from mealie.schema.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook + +router = APIRouter(prefix="/groups/cookbooks", tags=["Groups: Cookbooks"]) + + +@controller(router) +class GroupCookbookController(BaseUserController): + @cached_property + def repo(self): + return self.deps.repos.cookbooks.by_group(self.group_id) + + def registered_exceptions(self, ex: Type[Exception]) -> str: + registered = { + **mealie_registered_exceptions(self.deps.t), + } + return registered.get(ex, "An unexpected error occurred.") + + @cached_property + def mixins(self): + return CrudMixins[CreateCookBook, ReadCookBook, UpdateCookBook]( + self.repo, + self.deps.logger, + self.registered_exceptions, + ) + + @router.get("", response_model=list[RecipeCookBook]) + def get_all(self): + items = self.repo.get_all() + items.sort(key=lambda x: x.position) + return items + + @router.post("", response_model=RecipeCookBook, status_code=201) + def create_one(self, data: CreateCookBook): + data = mapper.cast(data, SaveCookBook, group_id=self.group_id) + return self.mixins.create_one(data) + + @router.put("", response_model=list[ReadCookBook]) + def update_many(self, data: list[UpdateCookBook]): + updated = [] + + for cookbook in data: + cb = self.mixins.update_one(cookbook, cookbook.id) + updated.append(cb) + + return updated + + @router.get("/{item_id}", response_model=RecipeCookBook) + def get_one(self, item_id: str): + try: + item_id = int(item_id) + return self.mixins.get_one(item_id) + except Exception: + self.mixins.get_one(item_id, key="slug") + + @router.put("/{item_id}", response_model=RecipeCookBook) + def update_one(self, item_id: int, data: CreateCookBook): + return self.mixins.update_one(data, item_id) + + @router.delete("/{item_id}", response_model=RecipeCookBook) + def delete_one(self, item_id: int): + return self.mixins.delete_one(item_id) diff --git a/mealie/routes/groups/notifications.py b/mealie/routes/groups/controller_group_notifications.py similarity index 95% rename from mealie/routes/groups/notifications.py rename to mealie/routes/groups/controller_group_notifications.py index 0efd482c9569..4c32e78e6303 100644 --- a/mealie/routes/groups/notifications.py +++ b/mealie/routes/groups/controller_group_notifications.py @@ -1,10 +1,10 @@ from functools import cached_property -from sqlite3 import IntegrityError from typing import Type from fastapi import APIRouter, Depends from pydantic import UUID4 +from mealie.core.exceptions import mealie_registered_exceptions from mealie.routes._base.controller import controller from mealie.routes._base.dependencies import SharedDependencies from mealie.routes._base.mixins import CrudMixins @@ -35,9 +35,9 @@ class GroupEventsNotifierController: return self.deps.repos.group_event_notifier.by_group(self.deps.acting_user.group_id) def registered_exceptions(self, ex: Type[Exception]) -> str: + registered = { - Exception: "An unexpected error occurred.", - IntegrityError: "An unexpected error occurred.", + **mealie_registered_exceptions(self.deps.t), } return registered.get(ex, "An unexpected error occurred.") diff --git a/mealie/routes/groups/controller_group_reports.py b/mealie/routes/groups/controller_group_reports.py new file mode 100644 index 000000000000..e2ef26624f7b --- /dev/null +++ b/mealie/routes/groups/controller_group_reports.py @@ -0,0 +1,45 @@ +from functools import cached_property +from typing import Type + +from fastapi import APIRouter +from pydantic import UUID4 + +from mealie.core.exceptions import mealie_registered_exceptions +from mealie.routes._base.abc_controller import BaseUserController +from mealie.routes._base.controller import controller +from mealie.routes._base.mixins import CrudMixins +from mealie.schema.reports.reports import ReportCategory, ReportCreate, ReportOut, ReportSummary + +router = APIRouter(prefix="/groups/reports", tags=["Groups: Reports"]) + + +@controller(router) +class GroupReportsController(BaseUserController): + @cached_property + def repo(self): + return self.deps.repos.group_reports.by_group(self.deps.acting_user.group_id) + + def registered_exceptions(self, ex: Type[Exception]) -> str: + return { + **mealie_registered_exceptions(self.deps.t), + }.get(ex, "An unexpected error occurred.") + + @cached_property + def mixins(self): + return CrudMixins[ReportCreate, ReportOut, ReportCreate]( + self.repo, + self.deps.logger, + self.registered_exceptions, + ) + + @router.get("", response_model=list[ReportSummary]) + def get_all(self, report_type: ReportCategory = None): + return self.repo.multi_query({"group_id": self.group_id, "category": report_type}, limit=9999) + + @router.get("/{item_id}", response_model=ReportOut) + def get_one(self, item_id: UUID4): + return self.mixins.get_one(item_id) + + @router.delete("/{item_id}", status_code=204) + def delete_one(self, item_id: UUID4): + self.mixins.delete_one(item_id) # type: ignore diff --git a/mealie/routes/groups/controller_group_self_service.py b/mealie/routes/groups/controller_group_self_service.py new file mode 100644 index 000000000000..3add0e8f9678 --- /dev/null +++ b/mealie/routes/groups/controller_group_self_service.py @@ -0,0 +1,49 @@ +from fastapi import HTTPException, status + +from mealie.routes._base.abc_controller import BaseUserController +from mealie.routes._base.controller import controller +from mealie.routes._base.routers import UserAPIRouter +from mealie.schema.group.group_permissions import SetPermissions +from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences +from mealie.schema.user.user import GroupInDB, UserOut + +router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"]) + + +@controller(router) +class GroupSelfServiceController(BaseUserController): + @router.get("/preferences", response_model=ReadGroupPreferences) + def get_group_preferences(self): + return self.group.preferences + + @router.put("/preferences", response_model=ReadGroupPreferences) + def update_group_preferences(self, new_pref: UpdateGroupPreferences): + return self.repos.group_preferences.update(self.group_id, new_pref) + + @router.get("/self", response_model=GroupInDB) + async def get_logged_in_user_group(self): + """Returns the Group Data for the Current User""" + return self.group + + @router.get("/members", response_model=list[UserOut]) + async def get_group_members(self): + """Returns the Group of user lists""" + return self.repos.users.multi_query(query_by={"group_id": self.group.id}, override_schema=UserOut) + + @router.put("/permissions", response_model=UserOut) + async def set_member_permissions(self, permissions: SetPermissions): + self.checks.can_manage() + + target_user = self.repos.users.get(permissions.user_id) + + if not target_user: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found") + + if target_user.group_id != self.group_id: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not a member of this group") + + target_user.can_invite = permissions.can_invite + target_user.can_manage = permissions.can_manage + target_user.can_organize = permissions.can_organize + + return self.repos.users.update(permissions.user_id, target_user) diff --git a/mealie/routes/groups/controller_invitations.py b/mealie/routes/groups/controller_invitations.py new file mode 100644 index 000000000000..313a237c8f7b --- /dev/null +++ b/mealie/routes/groups/controller_invitations.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, HTTPException, status + +from mealie.core.security import url_safe_token +from mealie.routes._base import BaseUserController, controller +from mealie.schema.group.invite_token import ( + CreateInviteToken, + EmailInitationResponse, + EmailInvitation, + ReadInviteToken, + SaveInviteToken, +) +from mealie.services.email.email_service import EmailService + +router = APIRouter(prefix="/groups/invitations", tags=["Groups: Invitations"]) + + +@controller(router) +class GroupInvitationsController(BaseUserController): + @router.get("", response_model=list[ReadInviteToken]) + def get_invite_tokens(self): + return self.repos.group_invite_tokens.multi_query({"group_id": self.group_id}) + + @router.post("", response_model=ReadInviteToken, status_code=status.HTTP_201_CREATED) + def create_invite_token(self, uses: CreateInviteToken): + if not self.deps.acting_user.can_invite: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not allowed to create invite tokens") + + token = SaveInviteToken(uses_left=uses.uses, group_id=self.group_id, token=url_safe_token()) + return self.repos.group_invite_tokens.create(token) + + @router.post("/email", response_model=EmailInitationResponse) + def email_invitation(self, invite: EmailInvitation): + email_service = EmailService() + url = f"{self.deps.settings.BASE_URL}/register?token={invite.token}" + + success = False + error = None + try: + success = email_service.send_invitation(address=invite.email, invitation_url=url) + except Exception as e: + error = str(e) + + return EmailInitationResponse(success=success, error=error) diff --git a/mealie/routes/groups/labels.py b/mealie/routes/groups/controller_labels.py similarity index 87% rename from mealie/routes/groups/labels.py rename to mealie/routes/groups/controller_labels.py index 10251c534347..19c1c69765a4 100644 --- a/mealie/routes/groups/labels.py +++ b/mealie/routes/groups/controller_labels.py @@ -1,10 +1,10 @@ from functools import cached_property -from sqlite3 import IntegrityError from typing import Type from fastapi import APIRouter, Depends from pydantic import UUID4 +from mealie.core.exceptions import mealie_registered_exceptions from mealie.routes._base.controller import controller from mealie.routes._base.dependencies import SharedDependencies from mealie.routes._base.mixins import CrudMixins @@ -17,15 +17,13 @@ from mealie.schema.labels import ( ) from mealie.schema.mapper import cast from mealie.schema.query import GetAll -from mealie.services.group_services.shopping_lists import ShoppingListService router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"]) @controller(router) -class ShoppingListRoutes: +class MultiPurposeLabelsController: deps: SharedDependencies = Depends(SharedDependencies.user) - service: ShoppingListService = Depends(ShoppingListService.private) @cached_property def repo(self): @@ -35,9 +33,9 @@ class ShoppingListRoutes: return self.deps.repos.group_multi_purpose_labels.by_group(self.deps.acting_user.group_id) def registered_exceptions(self, ex: Type[Exception]) -> str: + registered = { - Exception: "An unexpected error occurred.", - IntegrityError: "An unexpected error occurred.", + **mealie_registered_exceptions(self.deps.t), } return registered.get(ex, "An unexpected error occurred.") diff --git a/mealie/routes/groups/controller_mealplan.py b/mealie/routes/groups/controller_mealplan.py new file mode 100644 index 000000000000..1963194c3d0b --- /dev/null +++ b/mealie/routes/groups/controller_mealplan.py @@ -0,0 +1,62 @@ +from datetime import date, timedelta +from functools import cached_property +from typing import Type + +from fastapi import APIRouter + +from mealie.core.exceptions import mealie_registered_exceptions +from mealie.repos.repository_meals import RepositoryMeals +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.mixins import CrudMixins +from mealie.schema import mapper +from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry + +router = APIRouter(prefix="/groups/mealplans", tags=["Groups: Mealplans"]) + + +@controller(router) +class GroupMealplanController(BaseUserController): + @cached_property + def repo(self) -> RepositoryMeals: + return self.repos.meals.by_group(self.group_id) + + def registered_exceptions(self, ex: Type[Exception]) -> str: + registered = { + **mealie_registered_exceptions(self.deps.t), + } + return registered.get(ex, "An unexpected error occurred.") + + @cached_property + def mixins(self): + return CrudMixins[CreatePlanEntry, ReadPlanEntry, UpdatePlanEntry]( + self.repo, + self.deps.logger, + self.registered_exceptions, + ) + + @router.get("/today", tags=["Groups: Mealplans"]) + def get_todays_meals(self): + return self.repo.get_today(group_id=self.group_id) + + @router.get("", response_model=list[ReadPlanEntry]) + def get_all(self, start: date = None, limit: date = None): + start = start or date.today() - timedelta(days=999) + limit = limit or date.today() + timedelta(days=999) + return self.repo.get_slice(start, limit, group_id=self.group.id) + + @router.post("", response_model=ReadPlanEntry, status_code=201) + def create_one(self, data: CreatePlanEntry): + data = mapper.cast(data, SavePlanEntry, group_id=self.group.id) + return self.mixins.create_one(data) + + @router.get("/{item_id}", response_model=ReadPlanEntry) + def get_one(self, item_id: int): + return self.mixins.get_one(item_id) + + @router.put("/{item_id}", response_model=ReadPlanEntry) + def update_one(self, item_id: int, data: UpdatePlanEntry): + return self.mixins.update_one(data, item_id) + + @router.delete("/{item_id}", response_model=ReadPlanEntry) + def delete_one(self, item_id: int): + return self.mixins.delete_one(item_id) diff --git a/mealie/routes/groups/controller_meaplan_config.py b/mealie/routes/groups/controller_meaplan_config.py new file mode 100644 index 000000000000..e724d25308d8 --- /dev/null +++ b/mealie/routes/groups/controller_meaplan_config.py @@ -0,0 +1,26 @@ +from mealie.routes._base.abc_controller import BaseUserController +from mealie.routes._base.controller import controller +from mealie.routes._base.mixins import CrudMixins +from mealie.routes._base.routers import UserAPIRouter +from mealie.schema.recipe.recipe_category import CategoryBase +from mealie.schema.user.user import GroupInDB + +router = UserAPIRouter(prefix="/groups/categories", tags=["Groups: Mealplan Categories"]) + + +@controller(router) +class GroupMealplanConfigController(BaseUserController): + @property + def mixins(self): + return CrudMixins[GroupInDB, GroupInDB, GroupInDB](self.repos.groups, self.deps.logger) + + @router.get("", response_model=list[CategoryBase]) + def get_mealplan_categories(self): + data = self.mixins.get_one(self.deps.acting_user.group_id) + return data.categories + + @router.put("", response_model=list[CategoryBase]) + def update_mealplan_categories(self, new_categories: list[CategoryBase]): + data = self.mixins.get_one(self.deps.acting_user.group_id) + data.categories = new_categories + return self.mixins.update_one(data, data.id).categories diff --git a/mealie/routes/groups/controller_migrations.py b/mealie/routes/groups/controller_migrations.py new file mode 100644 index 000000000000..2a0c59d57779 --- /dev/null +++ b/mealie/routes/groups/controller_migrations.py @@ -0,0 +1,51 @@ +import shutil + +from fastapi import Depends, File, Form +from fastapi.datastructures import UploadFile + +from mealie.core.dependencies import temporary_zip_path +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.routers import UserAPIRouter +from mealie.schema.group.group_migration import SupportedMigrations +from mealie.schema.reports.reports import ReportSummary +from mealie.services.migrations import ChowdownMigrator, MealieAlphaMigrator, NextcloudMigrator, PaprikaMigrator + +router = UserAPIRouter(prefix="/groups/migrations", tags=["Group: Migrations"]) + + +@controller(router) +class GroupMigrationController(BaseUserController): + @router.post("", response_model=ReportSummary) + def start_data_migration( + self, + add_migration_tag: bool = Form(False), + migration_type: SupportedMigrations = Form(...), + archive: UploadFile = File(...), + temp_path: str = Depends(temporary_zip_path), + ): + # Save archive to temp_path + with temp_path.open("wb") as buffer: + shutil.copyfileobj(archive.file, buffer) + + args = { + "archive": temp_path, + "db": self.repos, + "session": self.deps.session, + "user_id": self.user.id, + "group_id": self.group_id, + "add_migration_tag": add_migration_tag, + } + + match migration_type: + case SupportedMigrations.chowdown: + migrator = ChowdownMigrator(**args) + case SupportedMigrations.mealie_alpha: + migrator = MealieAlphaMigrator(**args) + case SupportedMigrations.nextcloud: + migrator = NextcloudMigrator(**args) + case SupportedMigrations.paprika: + migrator = PaprikaMigrator(**args) + case _: + raise ValueError(f"Unsupported migration type: {migration_type}") + + return migrator.migrate(f"{migration_type.value.title()} Migration") diff --git a/mealie/routes/groups/shopping_lists.py b/mealie/routes/groups/controller_shopping_lists.py similarity index 89% rename from mealie/routes/groups/shopping_lists.py rename to mealie/routes/groups/controller_shopping_lists.py index 6219ef46b4f5..af60e1ce9df9 100644 --- a/mealie/routes/groups/shopping_lists.py +++ b/mealie/routes/groups/controller_shopping_lists.py @@ -1,12 +1,12 @@ from functools import cached_property -from sqlite3 import IntegrityError from typing import Type from fastapi import APIRouter, Depends from pydantic import UUID4 +from mealie.core.exceptions import mealie_registered_exceptions +from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.controller import controller -from mealie.routes._base.dependencies import SharedDependencies from mealie.routes._base.mixins import CrudMixins from mealie.schema.group.group_shopping_list import ( ShoppingListCreate, @@ -25,11 +25,13 @@ router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists @controller(router) -class ShoppingListRoutes: - deps: SharedDependencies = Depends(SharedDependencies.user) - service: ShoppingListService = Depends(ShoppingListService.private) +class ShoppingListController(BaseUserController): event_bus: EventBusService = Depends(EventBusService) + @cached_property + def service(self): + return ShoppingListService(self.repos) + @cached_property def repo(self): if not self.deps.acting_user: @@ -38,9 +40,9 @@ class ShoppingListRoutes: return self.deps.repos.group_shopping_lists.by_group(self.deps.acting_user.group_id) def registered_exceptions(self, ex: Type[Exception]) -> str: + registered = { - Exception: "An unexpected error occurred.", - IntegrityError: "An unexpected error occurred.", + **mealie_registered_exceptions(self.deps.t), } return registered.get(ex, "An unexpected error occurred.") diff --git a/mealie/routes/groups/controller_webhooks.py b/mealie/routes/groups/controller_webhooks.py new file mode 100644 index 000000000000..4f755afd122b --- /dev/null +++ b/mealie/routes/groups/controller_webhooks.py @@ -0,0 +1,44 @@ +from functools import cached_property + +from fastapi import APIRouter, Depends + +from mealie.routes._base.abc_controller import BaseUserController +from mealie.routes._base.controller import controller +from mealie.routes._base.mixins import CrudMixins +from mealie.schema import mapper +from mealie.schema.group.webhook import CreateWebhook, ReadWebhook, SaveWebhook +from mealie.schema.query import GetAll + +router = APIRouter(prefix="/groups/webhooks", tags=["Groups: Webhooks"]) + + +@controller(router) +class ReadWebhookController(BaseUserController): + @cached_property + def repo(self): + return self.repos.webhooks.by_group(self.group_id) + + @property + def mixins(self) -> CrudMixins: + return CrudMixins[CreateWebhook, SaveWebhook, CreateWebhook](self.repo, self.deps.logger) + + @router.get("", response_model=list[ReadWebhook]) + def get_all(self, q: GetAll = Depends(GetAll)): + return self.repo.get_all(start=q.start, limit=q.limit, override_schema=ReadWebhook) + + @router.post("", response_model=ReadWebhook, status_code=201) + def create_one(self, data: CreateWebhook): + save = mapper.cast(data, SaveWebhook, group_id=self.group.id) + return self.mixins.create_one(save) + + @router.get("/{item_id}", response_model=ReadWebhook) + def get_one(self, item_id: int): + return self.mixins.get_one(item_id) + + @router.put("/{item_id}", response_model=ReadWebhook) + def update_one(self, item_id: int, data: CreateWebhook): + return self.mixins.update_one(data, item_id) + + @router.delete("/{item_id}", response_model=ReadWebhook) + def delete_one(self, item_id: int): + return self.mixins.delete_one(item_id) # type: ignore diff --git a/mealie/routes/groups/invitations.py b/mealie/routes/groups/invitations.py deleted file mode 100644 index c80e990e767b..000000000000 --- a/mealie/routes/groups/invitations.py +++ /dev/null @@ -1,21 +0,0 @@ -from fastapi import APIRouter, Depends, status - -from mealie.schema.group.invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken -from mealie.services.group_services.group_service import GroupSelfService - -router = APIRouter() - - -@router.get("", response_model=list[ReadInviteToken]) -def get_invite_tokens(g_service: GroupSelfService = Depends(GroupSelfService.private)): - return g_service.get_invite_tokens() - - -@router.post("", response_model=ReadInviteToken, status_code=status.HTTP_201_CREATED) -def create_invite_token(uses: CreateInviteToken, g_service: GroupSelfService = Depends(GroupSelfService.private)): - return g_service.create_invite_token(uses.uses) - - -@router.post("/email", response_model=EmailInitationResponse) -def email_invitation(invite: EmailInvitation, g_service: GroupSelfService = Depends(GroupSelfService.private)): - return g_service.email_invitation(invite) diff --git a/mealie/routes/groups/migrations.py b/mealie/routes/groups/migrations.py deleted file mode 100644 index 89198b4c315e..000000000000 --- a/mealie/routes/groups/migrations.py +++ /dev/null @@ -1,27 +0,0 @@ -import shutil - -from fastapi import Depends, File, Form -from fastapi.datastructures import UploadFile - -from mealie.core.dependencies import temporary_zip_path -from mealie.routes.routers import UserAPIRouter -from mealie.schema.group.group_migration import SupportedMigrations -from mealie.schema.reports.reports import ReportSummary -from mealie.services.group_services.migration_service import GroupMigrationService - -router = UserAPIRouter() - - -@router.post("", response_model=ReportSummary) -def start_data_migration( - add_migration_tag: bool = Form(False), - migration_type: SupportedMigrations = Form(...), - archive: UploadFile = File(...), - temp_path: str = Depends(temporary_zip_path), - gm_service: GroupMigrationService = Depends(GroupMigrationService.private), -): - # Save archive to temp_path - with temp_path.open("wb") as buffer: - shutil.copyfileobj(archive.file, buffer) - - return gm_service.migrate(migration_type, add_migration_tag, temp_path) diff --git a/mealie/routes/groups/preferences.py b/mealie/routes/groups/preferences.py deleted file mode 100644 index 5020bb010f39..000000000000 --- a/mealie/routes/groups/preferences.py +++ /dev/null @@ -1,19 +0,0 @@ -from fastapi import Depends - -from mealie.routes.routers import UserAPIRouter -from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences -from mealie.services.group_services.group_service import GroupSelfService - -router = UserAPIRouter() - - -@router.put("", response_model=ReadGroupPreferences) -def update_group_preferences( - new_pref: UpdateGroupPreferences, g_service: GroupSelfService = Depends(GroupSelfService.write_existing) -): - return g_service.update_preferences(new_pref).preferences - - -@router.get("", response_model=ReadGroupPreferences) -def get_group_preferences(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)): - return g_service.item.preferences diff --git a/mealie/routes/groups/self_service.py b/mealie/routes/groups/self_service.py deleted file mode 100644 index f1099f3fe72c..000000000000 --- a/mealie/routes/groups/self_service.py +++ /dev/null @@ -1,27 +0,0 @@ -from fastapi import Depends - -from mealie.routes.routers import UserAPIRouter -from mealie.schema.group.group_permissions import SetPermissions -from mealie.schema.user.user import GroupInDB, UserOut -from mealie.services.group_services.group_service import GroupSelfService - -user_router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"]) - - -@user_router.get("/self", response_model=GroupInDB) -async def get_logged_in_user_group(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)): - """Returns the Group Data for the Current User""" - return g_service.item - - -@user_router.get("/members", response_model=list[UserOut]) -async def get_group_members(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)): - """Returns the Group of user lists""" - return g_service.get_members() - - -@user_router.put("/permissions", response_model=UserOut) -async def set_member_permissions( - payload: SetPermissions, g_service: GroupSelfService = Depends(GroupSelfService.manage_existing) -): - return g_service.set_member_permissions(payload) diff --git a/mealie/routes/parser/__init__.py b/mealie/routes/parser/__init__.py index 784f6a75d2e9..4ec38be998f8 100644 --- a/mealie/routes/parser/__init__.py +++ b/mealie/routes/parser/__init__.py @@ -3,4 +3,4 @@ from fastapi import APIRouter from . import ingredient_parser router = APIRouter() -router.include_router(ingredient_parser.public_router, tags=["Recipe: Ingredient Parser"]) +router.include_router(ingredient_parser.router, tags=["Recipe: Ingredient Parser"]) diff --git a/mealie/routes/parser/ingredient_parser.py b/mealie/routes/parser/ingredient_parser.py index fa89b64e4511..d68437bec953 100644 --- a/mealie/routes/parser/ingredient_parser.py +++ b/mealie/routes/parser/ingredient_parser.py @@ -1,25 +1,21 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter +from mealie.routes._base import BaseUserController, controller from mealie.schema.recipe import ParsedIngredient from mealie.schema.recipe.recipe_ingredient import IngredientRequest, IngredientsRequest -from mealie.services.parser_services import IngredientParserService +from mealie.services.parser_services import get_parser -public_router = APIRouter(prefix="/parser") +router = APIRouter(prefix="/parser") -@public_router.post("/ingredients", response_model=list[ParsedIngredient]) -def parse_ingredients( - ingredients: IngredientsRequest, - p_service: IngredientParserService = Depends(IngredientParserService.private), -): - p_service.set_parser(parser=ingredients.parser) - return p_service.parse_ingredients(ingredients.ingredients) +@controller(router) +class IngredientParserController(BaseUserController): + @router.post("/ingredients", response_model=list[ParsedIngredient]) + def parse_ingredients(self, ingredients: IngredientsRequest): + parser = get_parser(ingredients.parser) + return parser.parse(ingredients.ingredients) - -@public_router.post("/ingredient", response_model=ParsedIngredient) -def parse_ingredient( - ingredient: IngredientRequest, - p_service: IngredientParserService = Depends(IngredientParserService.private), -): - p_service.set_parser(parser=ingredient.parser) - return p_service.parse_ingredient(ingredient.ingredient) + @router.post("/ingredient", response_model=ParsedIngredient) + def parse_ingredient(self, ingredient: IngredientRequest): + parser = get_parser(ingredient.parser) + return parser.parse([ingredient.ingredient])[0] diff --git a/mealie/routes/recipe/__init__.py b/mealie/routes/recipe/__init__.py index 61c5254f3ef3..a5db7943e269 100644 --- a/mealie/routes/recipe/__init__.py +++ b/mealie/routes/recipe/__init__.py @@ -1,25 +1,16 @@ from fastapi import APIRouter -from . import ( - all_recipe_routes, - bulk_actions, - comments, - image_and_assets, - recipe_crud_routes, - recipe_export, - shared_routes, -) +from . import all_recipe_routes, bulk_actions, comments, image_and_assets, recipe_crud_routes, shared_routes prefix = "/recipes" router = APIRouter() router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"]) -router.include_router(recipe_export.user_router, prefix=prefix, tags=["Recipe: Exports"]) -router.include_router(recipe_export.public_router, prefix=prefix, tags=["Recipe: Exports"]) -router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Recipe: CRUD"]) -router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"]) +router.include_router(recipe_crud_routes.router_exports) +router.include_router(recipe_crud_routes.router) +router.include_router(image_and_assets.router, prefix=prefix, tags=["Recipe: Images and Assets"]) router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"]) -router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Actions"]) -router.include_router(bulk_actions.export_router, prefix=prefix, tags=["Recipe: Bulk Exports"]) +router.include_router(bulk_actions.router, prefix=prefix) +router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Exports"]) router.include_router(shared_routes.router, prefix=prefix, tags=["Recipe: Shared"]) diff --git a/mealie/routes/recipe/bulk_actions.py b/mealie/routes/recipe/bulk_actions.py index f1597100cc8e..50c932f770a9 100644 --- a/mealie/routes/recipe/bulk_actions.py +++ b/mealie/routes/recipe/bulk_actions.py @@ -1,9 +1,11 @@ +from functools import cached_property from pathlib import Path from fastapi import APIRouter, Depends from mealie.core.dependencies.dependencies import temporary_zip_path from mealie.core.security import create_file_token +from mealie.routes._base import BaseUserController, controller from mealie.schema.group.group_exports import GroupDataExport from mealie.schema.recipe.recipe_bulk_actions import ( AssignCategories, @@ -12,66 +14,46 @@ from mealie.schema.recipe.recipe_bulk_actions import ( DeleteRecipes, ExportRecipes, ) -from mealie.services.recipe.recipe_bulk_service import RecipeBulkActions +from mealie.schema.response.responses import SuccessResponse +from mealie.services.recipe.recipe_bulk_service import RecipeBulkActionsService -router = APIRouter(prefix="/bulk-actions") +router = APIRouter(prefix="/bulk-actions", tags=["Recipe: Bulk Actions"]) -@router.post("/tag", response_model=BulkActionsResponse) -def bulk_tag_recipes( - tag_data: AssignTags, - bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private), -): - bulk_service.assign_tags(tag_data.recipes, tag_data.tags) +@controller(router) +class RecipeBulkActionsController(BaseUserController): + @cached_property + def service(self) -> RecipeBulkActionsService: + return RecipeBulkActionsService(self.repos, self.user, self.group) + @router.post("/tag", response_model=BulkActionsResponse) + def bulk_tag_recipes(self, tag_data: AssignTags): + self.service.assign_tags(tag_data.recipes, tag_data.tags) -@router.post("/categorize", response_model=BulkActionsResponse) -def bulk_categorize_recipes( - assign_cats: AssignCategories, - bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private), -): - bulk_service.assign_categories(assign_cats.recipes, assign_cats.categories) + @router.post("/categorize", response_model=BulkActionsResponse) + def bulk_categorize_recipes(self, assign_cats: AssignCategories): + self.service.assign_categories(assign_cats.recipes, assign_cats.categories) + @router.post("/delete", response_model=BulkActionsResponse) + def bulk_delete_recipes(self, delete_recipes: DeleteRecipes): + self.service.delete_recipes(delete_recipes.recipes) -@router.post("/delete", response_model=BulkActionsResponse) -def bulk_delete_recipes( - delete_recipes: DeleteRecipes, - bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private), -): - bulk_service.delete_recipes(delete_recipes.recipes) + @router.post("/export", status_code=202) + def bulk_export_recipes(self, export_recipes: ExportRecipes, temp_path=Depends(temporary_zip_path)): + self.service.export_recipes(temp_path, export_recipes.recipes) + @router.get("/export/download") + def get_exported_data_token(self, path: Path): + """Returns a token to download a file""" -export_router = APIRouter(prefix="/bulk-actions") + return {"fileToken": create_file_token(path)} + @router.get("/export", response_model=list[GroupDataExport]) + def get_exported_data(self): + return self.service.get_exports() -@export_router.post("/export") -def bulk_export_recipes( - export_recipes: ExportRecipes, - temp_path=Depends(temporary_zip_path), - bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private), -): - bulk_service.export_recipes(temp_path, export_recipes.recipes) - - # return FileResponse(temp_path, filename="recipes.zip") - - -@export_router.get("/export/download") -def get_exported_data_token(path: Path, _: RecipeBulkActions = Depends(RecipeBulkActions.private)): - # return FileResponse(temp_path, filename="recipes.zip") - """Returns a token to download a file""" - - return {"fileToken": create_file_token(path)} - - -@export_router.get("/export", response_model=list[GroupDataExport]) -def get_exported_data(bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private)): - return bulk_service.get_exports() - - # return FileResponse(temp_path, filename="recipes.zip") - - -@export_router.delete("/export/purge") -def purge_export_data(bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private)): - """Remove all exports data, including items on disk without database entry""" - amountDelete = bulk_service.purge_exports() - return {"message": f"{amountDelete} exports deleted"} + @router.delete("/export/purge", response_model=SuccessResponse) + def purge_export_data(self): + """Remove all exports data, including items on disk without database entry""" + amountDelete = self.service.purge_exports() + return SuccessResponse.respond(f"{amountDelete} exports deleted") diff --git a/mealie/routes/recipe/comments.py b/mealie/routes/recipe/comments.py index d32cc7cd3e05..98a85bd8ba9c 100644 --- a/mealie/routes/recipe/comments.py +++ b/mealie/routes/recipe/comments.py @@ -1,20 +1,14 @@ -from fastapi import Depends -from sqlalchemy.orm.session import Session - -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import UserAPIRouter +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.routers import UserAPIRouter from mealie.schema.recipe.recipe_comments import RecipeCommentOut router = UserAPIRouter() -@router.get("/{slug}/comments", response_model=list[RecipeCommentOut]) -async def get_recipe_comments( - slug: str, - session: Session = Depends(generate_session), -): - """Get all comments for a recipe""" - db = get_repositories(session) - recipe = db.recipes.get_one(slug) - return db.comments.multi_query({"recipe_id": recipe.id}) +@controller(router) +class RecipeCommentsController(BaseUserController): + @router.get("/{slug}/comments", response_model=list[RecipeCommentOut]) + async def get_recipe_comments(self, slug: str): + """Get all comments for a recipe""" + recipe = self.repos.recipes.by_group(self.group_id).get_one(slug) + return self.repos.comments.multi_query({"recipe_id": recipe.id}) diff --git a/mealie/routes/recipe/image_and_assets.py b/mealie/routes/recipe/image_and_assets.py index 9a03fc380dcb..47e8741105b9 100644 --- a/mealie/routes/recipe/image_and_assets.py +++ b/mealie/routes/recipe/image_and_assets.py @@ -2,26 +2,30 @@ from shutil import copyfileobj from fastapi import Depends, File, Form, HTTPException, status from fastapi.datastructures import UploadFile +from pydantic import BaseModel from slugify import slugify from sqlalchemy.orm.session import Session from mealie.db.db_setup import generate_session from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import UserAPIRouter +from mealie.routes._base.routers import UserAPIRouter from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeAsset from mealie.services.image.image import scrape_image, write_image -user_router = UserAPIRouter() +router = UserAPIRouter() -@user_router.post("/{slug}/image") +class UpdateImageResponse(BaseModel): + image: str + + +@router.post("/{slug}/image") def scrape_image_url(slug: str, url: CreateRecipeByUrl): """Removes an existing image and replaces it with the incoming file.""" - scrape_image(url.url, slug) -@user_router.put("/{slug}/image") +@router.put("/{slug}/image", response_model=UpdateImageResponse) def update_recipe_image( slug: str, image: bytes = File(...), @@ -33,10 +37,10 @@ def update_recipe_image( write_image(slug, image, extension) new_version = db.recipes.update_image(slug, extension) - return {"image": new_version} + return UpdateImageResponse(image=new_version) -@user_router.post("/{slug}/assets", response_model=RecipeAsset) +@router.post("/{slug}/assets", response_model=RecipeAsset) def upload_recipe_asset( slug: str, name: str = Form(...), diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index f351654cf889..b705bbdca572 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -1,130 +1,245 @@ -from fastapi import Depends, File +from functools import cached_property +from zipfile import ZipFile + +import sqlalchemy +from fastapi import BackgroundTasks, Depends, File, HTTPException from fastapi.datastructures import UploadFile from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field from sqlalchemy.orm.session import Session +from starlette.responses import FileResponse +from mealie.core import exceptions from mealie.core.dependencies import temporary_zip_path -from mealie.core.root_logger import get_logger +from mealie.core.dependencies.dependencies import temporary_dir, validate_recipe_token +from mealie.core.security import create_recipe_slug_token from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import UserAPIRouter -from mealie.schema.recipe import CreateRecipeByUrl, Recipe +from mealie.repos.repository_recipes import RepositoryRecipes +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.mixins import CrudMixins +from mealie.routes._base.routers import UserAPIRouter +from mealie.schema.query import GetAll +from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeImageTypes from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary +from mealie.schema.response.responses import ErrorResponse from mealie.schema.server.tasks import ServerTaskNames from mealie.services.recipe.recipe_service import RecipeService +from mealie.services.recipe.template_service import TemplateService from mealie.services.scraper.scraper import create_from_url from mealie.services.scraper.scraper_strategies import RecipeScraperPackage from mealie.services.server_tasks.background_executory import BackgroundExecutor -user_router = UserAPIRouter() -logger = get_logger() + +class BaseRecipeController(BaseUserController): + @cached_property + def repo(self) -> RepositoryRecipes: + return self.repos.recipes.by_group(self.group_id) + + @cached_property + def service(self) -> RecipeService: + return RecipeService(self.repos, self.user, self.group) + + @cached_property + def mixins(self): + return CrudMixins[CreateRecipe, Recipe, Recipe](self.repo, self.deps.logger) -@user_router.get("", response_model=list[RecipeSummary]) -async def get_all( - start: int = 0, - limit: int = None, - load_foods: bool = False, - service: RecipeService = Depends(RecipeService.private), -): - json_compatible_item_data = jsonable_encoder(service.get_all(start, limit, load_foods)) - return JSONResponse(content=json_compatible_item_data) +class RecipeGetAll(GetAll): + load_food: bool = False -@user_router.post("", status_code=201, response_model=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_one(data).slug +class FormatResponse(BaseModel): + jjson: list[str] = Field(..., alias="json") + zip: list[str] + jinja2: list[str] -@user_router.post("/create-url", status_code=201, response_model=str) -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) - return recipe_service.create_one(recipe).slug +router_exports = UserAPIRouter(prefix="/recipes", tags=["Recipe: Exports"]) -@user_router.post("/create-url/bulk", status_code=202) -def parse_recipe_url_bulk( - bulk: CreateRecipeByUrlBulk, - recipe_service: RecipeService = Depends(RecipeService.private), - bg_service: BackgroundExecutor = Depends(BackgroundExecutor.private), -): - """Takes in a URL and attempts to scrape data and load it into the database""" +@controller(router_exports) +class RecipeExportController(BaseRecipeController): + # ================================================================================================================== + # Export Operations - def bulk_import_func(task_id: int, session: Session) -> None: - database = get_repositories(session) - task = database.server_tasks.get_one(task_id) + @router_exports.get("/exports", response_model=FormatResponse) + def get_recipe_formats_and_templates(self): + return TemplateService().templates - task.append_log("test task has started") + @router_exports.post("/{slug}/exports") + def get_recipe_zip_token(self, slug: str): + """Generates a recipe zip token to be used to download a recipe as a zip file""" + return {"token": create_recipe_slug_token(slug)} - for b in bulk.imports: - try: - recipe = create_from_url(b.url) + @router_exports.get("/{slug}/exports", response_class=FileResponse) + def get_recipe_as_format(self, slug: str, template_name: str, temp_dir=Depends(temporary_dir)): + """ + ## Parameters + `template_name`: The name of the template to use to use in the exports listed. Template type will automatically + be set on the backend. Because of this, it's important that your templates have unique names. See available + names and formats in the /api/recipes/exports endpoint. - if b.tags: - recipe.tags = b.tags + """ + recipe = self.mixins.get_one(slug) + file = self.service.render_template(recipe, temp_dir, template_name) + return FileResponse(file) - if b.categories: - recipe.recipe_category = b.categories + @router_exports.get("/{slug}/exports/zip") + def get_recipe_as_zip(self, slug: str, token: str, temp_path=Depends(temporary_zip_path)): + """Get a Recipe and It's Original Image as a Zip File""" + slug = validate_recipe_token(token) - recipe_service.create_one(recipe) - task.append_log(f"INFO: Created recipe from url: {b.url}") - except Exception as e: - task.append_log(f"Error: Failed to create recipe from url: {b.url}") - task.append_log(f"Error: {e}") - logger.error(f"Failed to create recipe from url: {b.url}") - logger.error(e) + if slug != slug: + raise HTTPException(status_code=400, detail="Invalid Slug") + + recipe: Recipe = self.mixins.get_one(slug) + image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value) + with ZipFile(temp_path, "w") as myzip: + myzip.writestr(f"{slug}.json", recipe.json()) + + if image_asset.is_file(): + myzip.write(image_asset, arcname=image_asset.name) + + return FileResponse(temp_path, filename=f"{slug}.zip") + + +router = UserAPIRouter(prefix="/recipes", tags=["Recipe: CRUD"]) + + +@controller(router) +class RecipeController(BaseRecipeController): + # ======================================================================= + # URL Scraping Operations + + @router.post("/create-url", status_code=201, response_model=str) + def parse_recipe_url(self, url: CreateRecipeByUrl): + """Takes in a URL and attempts to scrape data and load it into the database""" + recipe = create_from_url(url.url) + return self.service.create_one(recipe).slug + + @router.post("/create-url/bulk", status_code=202) + def parse_recipe_url_bulk(self, bulk: CreateRecipeByUrlBulk, bg_tasks: BackgroundTasks): + """Takes in a URL and attempts to scrape data and load it into the database""" + bg_executor = BackgroundExecutor(self.group.id, self.repos, bg_tasks) + + def bulk_import_func(task_id: int, session: Session) -> None: + database = get_repositories(session) + task = database.server_tasks.get_one(task_id) + + task.append_log("test task has started") + + for b in bulk.imports: + try: + recipe = create_from_url(b.url) + + if b.tags: + recipe.tags = b.tags + + if b.categories: + recipe.recipe_category = b.categories + + self.service.create_one(recipe) + task.append_log(f"INFO: Created recipe from url: {b.url}") + except Exception as e: + task.append_log(f"Error: Failed to create recipe from url: {b.url}") + task.append_log(f"Error: {e}") + self.deps.logger.error(f"Failed to create recipe from url: {b.url}") + self.deps.error(e) + database.server_tasks.update(task.id, task) + + task.set_finished() database.server_tasks.update(task.id, task) - task.set_finished() - database.server_tasks.update(task.id, task) + bg_executor.dispatch(ServerTaskNames.bulk_recipe_import, bulk_import_func) - bg_service.dispatch(ServerTaskNames.bulk_recipe_import, bulk_import_func) + return {"details": "task has been started"} - return {"details": "task has been started"} + @router.post("/test-scrape-url") + def test_parse_recipe_url(self, url: CreateRecipeByUrl): + # Debugger should produce the same result as the scraper sees before cleaning + scraped_data = RecipeScraperPackage(url.url).scrape_url() + if scraped_data: + return scraped_data.schema.data + return "recipe_scrapers was unable to scrape this URL" -@user_router.post("/test-scrape-url") -def test_parse_recipe_url(url: CreateRecipeByUrl): - # Debugger should produce the same result as the scraper sees before cleaning - scraped_data = RecipeScraperPackage(url.url).scrape_url() + @router.post("/create-from-zip", status_code=201) + def create_recipe_from_zip(self, temp_path=Depends(temporary_zip_path), archive: UploadFile = File(...)): + """Create recipe from archive""" + recipe = self.service.create_from_zip(archive, temp_path) + return recipe.slug - if scraped_data: - return scraped_data.schema.data - return "recipe_scrapers was unable to scrape this URL" + # ================================================================================================================== + # CRUD Operations + @router.get("", response_model=list[RecipeSummary]) + def get_all(self, q: RecipeGetAll = Depends(RecipeGetAll)): + items = self.repo.summary(self.user.group_id, start=q.start, limit=q.limit, load_foods=q.load_food) -@user_router.post("/create-from-zip", status_code=201) -async def create_recipe_from_zip( - recipe_service: RecipeService = Depends(RecipeService.private), - temp_path=Depends(temporary_zip_path), - archive: UploadFile = File(...), -): - """Create recipe from archive""" - recipe = recipe_service.create_from_zip(archive, temp_path) - return recipe.slug + new_items = [] + for item in items: + # Pydantic/FastAPI can't seem to serialize the ingredient field on thier own. + new_item = item.__dict__ + if q.load_food: + new_item["recipe_ingredient"] = [x.__dict__ for x in item.recipe_ingredient] -@user_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""" - return recipe_service.item + new_items.append(new_item) + json_compatible_item_data = jsonable_encoder(RecipeSummary.construct(**x) for x in new_items) -@user_router.put("/{slug}") -def update_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)): - """Updates a recipe by existing slug and data.""" - return recipe_service.update_one(data) + # Response is returned directly, to avoid validation and improve performance + return JSONResponse(content=json_compatible_item_data) + @router.get("/{slug}", response_model=Recipe) + def get_one(self, slug: str): + """Takes in a recipe slug, returns all data for a recipe""" + return self.mixins.get_one(slug) -@user_router.patch("/{slug}") -def patch_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)): - """Updates a recipe by existing slug and data.""" - return recipe_service.patch_one(data) + @router.post("", status_code=201, response_model=str) + def create_one(self, data: CreateRecipe) -> str: + """Takes in a JSON string and loads data into the database as a new entry""" + try: + return self.service.create_one(data).slug + except Exception as e: + self.handle_exceptions(e) + def handle_exceptions(self, ex: Exception) -> None: + match type(ex): + case exceptions.PermissionDenied: + self.deps.logger.error("Permission Denied on recipe controller action") + raise HTTPException(status_code=403, detail=ErrorResponse.respond(message="Permission Denied")) + case exceptions.NoEntryFound: + self.deps.logger.error("No Entry Found on recipe controller action") + raise HTTPException(status_code=404, detail=ErrorResponse.respond(message="No Entry Found")) + case sqlalchemy.exc.IntegrityError: + self.deps.logger.error("SQL Integrity Error on recipe controller action") + raise HTTPException(status_code=400, detail=ErrorResponse.respond(message="Recipe already exists")) -@user_router.delete("/{slug}") -def delete_recipe(recipe_service: RecipeService = Depends(RecipeService.write_existing)): - """Deletes a recipe by slug""" - return recipe_service.delete_one() + @router.put("/{slug}") + def update_one(self, slug: str, data: Recipe): + """Updates a recipe by existing slug and data.""" + try: + data = self.service.update_one(slug, data) + except Exception as e: + self.handle_exceptions(e) + + return data + + @router.patch("/{slug}") + def patch_one(self, slug: str, data: Recipe): + """Updates a recipe by existing slug and data.""" + try: + data = self.service.patch_one(slug, data) + except Exception as e: + self.handle_exceptions(e) + return data + + @router.delete("/{slug}") + def delete_one(self, slug: str): + """Deletes a recipe by slug""" + try: + return self.service.delete_one(slug) + except Exception as e: + self.handle_exceptions(e) diff --git a/mealie/routes/recipe/recipe_export.py b/mealie/routes/recipe/recipe_export.py deleted file mode 100644 index 77bd18c12fe4..000000000000 --- a/mealie/routes/recipe/recipe_export.py +++ /dev/null @@ -1,80 +0,0 @@ -from zipfile import ZipFile - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field -from sqlalchemy.orm.session import Session -from starlette.responses import FileResponse - -from mealie.core.dependencies import temporary_zip_path -from mealie.core.dependencies.dependencies import temporary_dir, validate_recipe_token -from mealie.core.root_logger import get_logger -from mealie.core.security import create_recipe_slug_token -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import UserAPIRouter -from mealie.schema.recipe import Recipe, RecipeImageTypes -from mealie.services.recipe.recipe_service import RecipeService -from mealie.services.recipe.template_service import TemplateService - -user_router = UserAPIRouter() -public_router = APIRouter() -logger = get_logger() - - -class FormatResponse(BaseModel): - jjson: list[str] = Field(..., alias="json") - zip: list[str] - jinja2: list[str] - - -@user_router.get("/exports", response_model=FormatResponse) -async def get_recipe_formats_and_templates(_: RecipeService = Depends(RecipeService.private)): - return TemplateService().templates - - -@user_router.post("/{slug}/exports") -async def get_recipe_zip_token(slug: str): - """Generates a recipe zip token to be used to download a recipe as a zip file""" - return {"token": create_recipe_slug_token(slug)} - - -@user_router.get("/{slug}/exports", response_class=FileResponse) -def get_recipe_as_format( - template_name: str, - recipe_service: RecipeService = Depends(RecipeService.write_existing), - temp_dir=Depends(temporary_dir), -): - """ - ## Parameters - `template_name`: The name of the template to use to use in the exports listed. Template type will automatically - be set on the backend. Because of this, it's important that your templates have unique names. See available - names and formats in the /api/recipes/exports endpoint. - - """ - file = recipe_service.render_template(temp_dir, template_name) - return FileResponse(file) - - -@public_router.get("/{slug}/exports/zip") -async def get_recipe_as_zip( - token: str, - slug: str, - session: Session = Depends(generate_session), - temp_path=Depends(temporary_zip_path), -): - """Get a Recipe and It's Original Image as a Zip File""" - slug = validate_recipe_token(token) - - if slug != slug: - raise HTTPException(status_code=400, detail="Invalid Slug") - - db = get_repositories(session) - recipe: Recipe = db.recipes.get(slug) - image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value) - with ZipFile(temp_path, "w") as myzip: - myzip.writestr(f"{slug}.json", recipe.json()) - - if image_asset.is_file(): - myzip.write(image_asset, arcname=image_asset.name) - - return FileResponse(temp_path, filename=f"{slug}.zip") diff --git a/mealie/routes/shared/__init__.py b/mealie/routes/shared/__init__.py index 8439613c71f5..af0d197c5b05 100644 --- a/mealie/routes/shared/__init__.py +++ b/mealie/routes/shared/__init__.py @@ -1,23 +1,42 @@ -from fastapi import Depends +from functools import cached_property -from mealie.routes.routers import UserAPIRouter -from mealie.services._base_http_service.router_factory import RouterFactory -from mealie.services.shared.recipe_shared_service import RecipeShareTokenSummary, SharedRecipeService +from pydantic import UUID4 -router = UserAPIRouter(prefix="/shared") +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.mixins import CrudMixins +from mealie.routes._base.routers import UserAPIRouter +from mealie.schema.recipe import RecipeShareTokenSummary +from mealie.schema.recipe.recipe_share_token import RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenSave -shared_router = RouterFactory(SharedRecipeService, prefix="/recipes", tags=["Shared: Recipes"]) +router = UserAPIRouter(prefix="/shared/recipes", tags=["Shared: Recipes"]) -@shared_router.get("", response_model=list[RecipeShareTokenSummary]) -def get_all_shared( - recipe_id: int = None, - shared_recipe_service: SharedRecipeService = Depends(SharedRecipeService.private), -): - """ - Get all shared recipes - """ - return shared_recipe_service.get_all(recipe_id) +@controller(router) +class RecipeSharedController(BaseUserController): + @cached_property + def repo(self): + return self.repos.recipe_share_tokens.by_group(self.group_id) + @cached_property + def mixins(self): + return CrudMixins[RecipeShareTokenSave, RecipeShareToken, RecipeShareTokenCreate](self.repo, self.deps.logger) -router.include_router(shared_router) + @router.get("", response_model=list[RecipeShareTokenSummary]) + def get_all(self, recipe_id: int = None): + if recipe_id: + return self.repo.multi_query({"recipe_id": recipe_id}, override_schema=RecipeShareTokenSummary) + else: + return self.repo.get_all(override_schema=RecipeShareTokenSummary) + + @router.post("", response_model=RecipeShareToken, status_code=201) + def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken: + save_data = RecipeShareTokenSave(**data.dict(), group_id=self.group_id) + return self.mixins.create_one(save_data) + + @router.get("/{item_id}", response_model=RecipeShareToken) + def get_one(self, item_id: UUID4): + return self.mixins.get_one(item_id) + + @router.delete("/{item_id}") + def delete_one(self, item_id: UUID4 = None) -> None: + return self.mixins.delete_one(item_id) diff --git a/mealie/routes/site_settings/__init__.py b/mealie/routes/site_settings/__init__.py deleted file mode 100644 index 432926070909..000000000000 --- a/mealie/routes/site_settings/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from fastapi import APIRouter - -from . import site_settings - -settings_router = APIRouter() - -settings_router.include_router(site_settings.public_router) -settings_router.include_router(site_settings.admin_router) diff --git a/mealie/routes/site_settings/site_settings.py b/mealie/routes/site_settings/site_settings.py deleted file mode 100644 index 70c39cb1b0d8..000000000000 --- a/mealie/routes/site_settings/site_settings.py +++ /dev/null @@ -1,35 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm.session import Session - -from mealie.core.dependencies import get_current_user -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import AdminAPIRouter -from mealie.schema.user import GroupInDB, PrivateUser -from mealie.utils.post_webhooks import post_webhooks - -public_router = APIRouter(prefix="/api/site-settings", tags=["Settings"]) -admin_router = AdminAPIRouter(prefix="/api/site-settings", tags=["Settings"]) - - -@public_router.get("") -def get_main_settings(session: Session = Depends(generate_session)): - """Returns basic site settings""" - db = get_repositories(session) - - return db.settings.get(1) - - -@admin_router.post("/webhooks/test") -def test_webhooks( - current_user: PrivateUser = Depends(get_current_user), - session: Session = Depends(generate_session), -): - """Run the function to test your webhooks""" - db = get_repositories(session) - group_entry: GroupInDB = db.groups.get(current_user.group, "name") - - try: - post_webhooks(group_entry.id, session) - except Exception: - return HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/mealie/routes/tags/__init__.py b/mealie/routes/tags/__init__.py index 719a6e23abe5..b405931b9a6b 100644 --- a/mealie/routes/tags/__init__.py +++ b/mealie/routes/tags/__init__.py @@ -1,11 +1,51 @@ -from fastapi import APIRouter +from functools import cached_property -from . import tags +from fastapi import APIRouter, HTTPException, status -prefix = "/tags" +from mealie.routes._base import BaseUserController, controller +from mealie.schema.recipe import RecipeTagResponse, TagIn -router = APIRouter() +router = APIRouter(prefix="/tags", tags=["Tags: CRUD"]) -router.include_router(tags.public_router, prefix=prefix, tags=["Tags: CRUD"]) -router.include_router(tags.user_router, prefix=prefix, tags=["Tags: CRUD"]) -router.include_router(tags.admin_router, prefix=prefix, tags=["Tags: CRUD"]) + +@controller(router) +class TagController(BaseUserController): + @cached_property + def repo(self): + return self.repos.tags + + @router.get("") + async def get_all_recipe_tags(self): + """Returns a list of available tags in the database""" + return self.repo.get_all_limit_columns(["slug", "name"]) + + @router.get("/empty") + def get_empty_tags(self): + """Returns a list of tags that do not contain any recipes""" + return self.repo.get_empty() + + @router.get("/{tag_slug}", response_model=RecipeTagResponse) + def get_all_recipes_by_tag(self, tag_slug: str): + """Returns a list of recipes associated with the provided tag.""" + return self.repo.get_one(tag_slug, override_schema=RecipeTagResponse) + + @router.post("", status_code=201) + def create_recipe_tag(self, tag: TagIn): + """Creates a Tag in the database""" + return self.repo.create(tag) + + @router.put("/{tag_slug}", response_model=RecipeTagResponse) + def update_recipe_tag(self, tag_slug: str, new_tag: TagIn): + """Updates an existing Tag in the database""" + return self.repo.update(tag_slug, new_tag) + + @router.delete("/{tag_slug}") + def delete_recipe_tag(self, tag_slug: str): + """Removes a recipe tag from the database. Deleting a + tag does not impact a recipe. The tag will be removed + from any recipes that contain it""" + + try: + self.repo.delete(tag_slug) + except Exception: + raise HTTPException(status.HTTP_400_BAD_REQUEST) diff --git a/mealie/routes/tags/tags.py b/mealie/routes/tags/tags.py deleted file mode 100644 index d63bbcb13379..000000000000 --- a/mealie/routes/tags/tags.py +++ /dev/null @@ -1,68 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session - -from mealie.core.dependencies import is_logged_in -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import AdminAPIRouter, UserAPIRouter -from mealie.schema.recipe import RecipeTagResponse, TagIn - -public_router = APIRouter() -user_router = UserAPIRouter() -admin_router = AdminAPIRouter() - - -@public_router.get("") -async def get_all_recipe_tags(session: Session = Depends(generate_session)): - """Returns a list of available tags in the database""" - db = get_repositories(session) - return db.tags.get_all_limit_columns(["slug", "name"]) - - -@public_router.get("/empty") -def get_empty_tags(session: Session = Depends(generate_session)): - """Returns a list of tags that do not contain any recipes""" - db = get_repositories(session) - return db.tags.get_empty() - - -@public_router.get("/{tag}", response_model=RecipeTagResponse) -def get_all_recipes_by_tag( - tag: str, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in) -): - """Returns a list of recipes associated with the provided tag.""" - db = get_repositories(session) - tag_obj = db.tags.get(tag) - tag_obj = RecipeTagResponse.from_orm(tag_obj) - - if not is_user: - tag_obj.recipes = [x for x in tag_obj.recipes if x.settings.public] - - return tag_obj - - -@user_router.post("") -async def create_recipe_tag(tag: TagIn, session: Session = Depends(generate_session)): - """Creates a Tag in the database""" - db = get_repositories(session) - return db.tags.create(tag.dict()) - - -@admin_router.put("/{tag}", response_model=RecipeTagResponse) -async def update_recipe_tag(tag: str, new_tag: TagIn, session: Session = Depends(generate_session)): - """Updates an existing Tag in the database""" - db = get_repositories(session) - return db.tags.update(tag, new_tag.dict()) - - -@admin_router.delete("/{tag}") -async def delete_recipe_tag(tag: str, session: Session = Depends(generate_session)): - """Removes a recipe tag from the database. Deleting a - tag does not impact a recipe. The tag will be removed - from any recipes that contain it""" - - try: - db = get_repositories(session) - db.tags.delete(tag) - except Exception: - raise HTTPException(status.HTTP_400_BAD_REQUEST) diff --git a/mealie/routes/tools/__init__.py b/mealie/routes/tools/__init__.py index 00b9deed03a7..212669206e54 100644 --- a/mealie/routes/tools/__init__.py +++ b/mealie/routes/tools/__init__.py @@ -1,18 +1,47 @@ +from functools import cached_property + from fastapi import APIRouter, Depends -from mealie.schema.recipe.recipe_tool import RecipeToolResponse -from mealie.services._base_http_service.router_factory import RouterFactory -from mealie.services.recipe.recipe_tool_service import RecipeToolService +from mealie.routes._base.abc_controller import BaseUserController +from mealie.routes._base.controller import controller +from mealie.routes._base.mixins import CrudMixins +from mealie.schema.query import GetAll +from mealie.schema.recipe.recipe import RecipeTool +from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse -router = APIRouter() - -tools_router = RouterFactory(RecipeToolService, prefix="/tools", tags=["Recipes: Tools"]) +router = APIRouter(prefix="/tools", tags=["Recipes: Tools"]) -@tools_router.get("/slug/{slug}") -async def Func(slug: str, tools_service: RecipeToolService = Depends(RecipeToolService.private)): - """Returns a recipe by slug.""" - return tools_service.db.tools.get_one(slug, "slug", override_schema=RecipeToolResponse) +@controller(router) +class RecipeToolController(BaseUserController): + @cached_property + def repo(self): + return self.repos.tools + @property + def mixins(self) -> CrudMixins: + return CrudMixins[RecipeToolCreate, RecipeTool, RecipeToolCreate](self.repo, self.deps.logger) -router.include_router(tools_router) + @router.get("", response_model=list[RecipeTool]) + def get_all(self, q: GetAll = Depends(GetAll)): + return self.repo.get_all(start=q.start, limit=q.limit, override_schema=RecipeTool) + + @router.post("", response_model=RecipeTool, status_code=201) + def create_one(self, data: RecipeToolCreate): + return self.mixins.create_one(data) + + @router.get("/{item_id}", response_model=RecipeTool) + def get_one(self, item_id: int): + return self.mixins.get_one(item_id) + + @router.put("/{item_id}", response_model=RecipeTool) + def update_one(self, item_id: int, data: RecipeToolCreate): + return self.mixins.update_one(data, item_id) + + @router.delete("/{item_id}", response_model=RecipeTool) + def delete_one(self, item_id: int): + return self.mixins.delete_one(item_id) # type: ignore + + @router.get("/slug/{tool_slug}", response_model=RecipeToolResponse) + async def get_one_by_slug(self, tool_slug: str): + return self.repo.get_one(tool_slug, "slug", override_schema=RecipeToolResponse) diff --git a/mealie/routes/unit_and_foods/__init__.py b/mealie/routes/unit_and_foods/__init__.py index 955619a8e63d..004b714ceff1 100644 --- a/mealie/routes/unit_and_foods/__init__.py +++ b/mealie/routes/unit_and_foods/__init__.py @@ -1,10 +1,8 @@ from fastapi import APIRouter -from mealie.services._base_http_service.router_factory import RouterFactory -from mealie.services.recipe.recipe_food_service import RecipeFoodService -from mealie.services.recipe.recipe_unit_service import RecipeUnitService +from . import foods, units router = APIRouter() -router.include_router(RouterFactory(RecipeFoodService, prefix="/foods", tags=["Recipes: Foods"])) -router.include_router(RouterFactory(RecipeUnitService, prefix="/units", tags=["Recipes: Units"])) +router.include_router(foods.router) +router.include_router(units.router) diff --git a/mealie/routes/unit_and_foods/foods.py b/mealie/routes/unit_and_foods/foods.py new file mode 100644 index 000000000000..850c3d0f2253 --- /dev/null +++ b/mealie/routes/unit_and_foods/foods.py @@ -0,0 +1,56 @@ +from functools import cached_property +from typing import Type + +from fastapi import APIRouter, Depends + +from mealie.core.exceptions import mealie_registered_exceptions +from mealie.routes._base.abc_controller import BaseUserController +from mealie.routes._base.controller import controller +from mealie.routes._base.mixins import CrudMixins +from mealie.schema.query import GetAll +from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit + +router = APIRouter(prefix="/foods", tags=["Recipes: Foods"]) + + +@controller(router) +class IngredientFoodsController(BaseUserController): + @cached_property + def repo(self): + return self.deps.repos.ingredient_foods + + def registered_exceptions(self, ex: Type[Exception]) -> str: + + registered = { + **mealie_registered_exceptions(self.deps.t), + } + + return registered.get(ex, "An unexpected error occurred.") + + @cached_property + def mixins(self): + return CrudMixins[CreateIngredientUnit, IngredientUnit, CreateIngredientUnit]( + self.repo, + self.deps.logger, + self.registered_exceptions, + ) + + @router.get("", response_model=list[IngredientUnit]) + def get_all(self, q: GetAll = Depends(GetAll)): + return self.repo.get_all(start=q.start, limit=q.limit) + + @router.post("", response_model=IngredientUnit, status_code=201) + def create_one(self, data: CreateIngredientUnit): + return self.mixins.create_one(data) + + @router.get("/{item_id}", response_model=IngredientUnit) + def get_one(self, item_id: int): + return self.mixins.get_one(item_id) + + @router.put("/{item_id}", response_model=IngredientUnit) + def update_one(self, item_id: int, data: CreateIngredientUnit): + return self.mixins.update_one(data, item_id) + + @router.delete("/{item_id}", response_model=IngredientUnit) + def delete_one(self, item_id: int): + return self.mixins.delete_one(item_id) diff --git a/mealie/routes/unit_and_foods/units.py b/mealie/routes/unit_and_foods/units.py new file mode 100644 index 000000000000..36164cce94bb --- /dev/null +++ b/mealie/routes/unit_and_foods/units.py @@ -0,0 +1,56 @@ +from functools import cached_property +from typing import Type + +from fastapi import APIRouter, Depends + +from mealie.core.exceptions import mealie_registered_exceptions +from mealie.routes._base.abc_controller import BaseUserController +from mealie.routes._base.controller import controller +from mealie.routes._base.mixins import CrudMixins +from mealie.schema.query import GetAll +from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit + +router = APIRouter(prefix="/units", tags=["Recipes: Units"]) + + +@controller(router) +class IngredientUnitsController(BaseUserController): + @cached_property + def repo(self): + return self.deps.repos.ingredient_units + + def registered_exceptions(self, ex: Type[Exception]) -> str: + + registered = { + **mealie_registered_exceptions(self.deps.t), + } + + return registered.get(ex, "An unexpected error occurred.") + + @cached_property + def mixins(self): + return CrudMixins[CreateIngredientUnit, IngredientUnit, CreateIngredientUnit]( + self.repo, + self.deps.logger, + self.registered_exceptions, + ) + + @router.get("", response_model=list[IngredientUnit]) + def get_all(self, q: GetAll = Depends(GetAll)): + return self.repo.get_all(start=q.start, limit=q.limit) + + @router.post("", response_model=IngredientUnit, status_code=201) + def create_one(self, data: CreateIngredientUnit): + return self.mixins.create_one(data) + + @router.get("/{item_id}", response_model=IngredientUnit) + def get_one(self, item_id: int): + return self.mixins.get_one(item_id) + + @router.put("/{item_id}", response_model=IngredientUnit) + def update_one(self, item_id: int, data: CreateIngredientUnit): + return self.mixins.update_one(data, item_id) + + @router.delete("/{item_id}", response_model=IngredientUnit) + def delete_one(self, item_id: int): + return self.mixins.delete_one(item_id) # type: ignore diff --git a/mealie/routes/users/__init__.py b/mealie/routes/users/__init__.py index dc4171671c54..aec44e11219a 100644 --- a/mealie/routes/users/__init__.py +++ b/mealie/routes/users/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from . import api_tokens, crud, favorites, images, passwords, registration +from . import api_tokens, crud, favorites, forgot_password, images, registration # Must be used because of the way FastAPI works with nested routes user_prefix = "/users" @@ -8,16 +8,9 @@ user_prefix = "/users" router = APIRouter() router.include_router(registration.router, prefix=user_prefix, tags=["Users: Registration"]) - -router.include_router(crud.user_router, prefix=user_prefix, tags=["Users: CRUD"]) -router.include_router(crud.admin_router, prefix=user_prefix, tags=["Users: CRUD"]) - -router.include_router(passwords.user_router, prefix=user_prefix, tags=["Users: Passwords"]) -router.include_router(passwords.public_router, prefix=user_prefix, tags=["Users: Passwords"]) - -router.include_router(images.public_router, prefix=user_prefix, tags=["Users: Images"]) -router.include_router(images.user_router, prefix=user_prefix, tags=["Users: Images"]) - -router.include_router(api_tokens.router, prefix=user_prefix, tags=["Users: Tokens"]) - -router.include_router(favorites.user_router, prefix=user_prefix, tags=["Users: Favorites"]) +router.include_router(crud.user_router) +router.include_router(crud.admin_router) +router.include_router(forgot_password.router, prefix=user_prefix, tags=["Users: Passwords"]) +router.include_router(images.router, prefix=user_prefix, tags=["Users: Images"]) +router.include_router(api_tokens.router) +router.include_router(favorites.router, prefix=user_prefix, tags=["Users: Favorites"]) diff --git a/mealie/routes/users/api_tokens.py b/mealie/routes/users/api_tokens.py index 2e085ab92a80..b65d3dc4bc27 100644 --- a/mealie/routes/users/api_tokens.py +++ b/mealie/routes/users/api_tokens.py @@ -1,61 +1,50 @@ from datetime import timedelta from fastapi import HTTPException, status -from fastapi.param_functions import Depends -from sqlalchemy.orm.session import Session -from mealie.core.dependencies import get_current_user from mealie.core.security import create_access_token -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import UserAPIRouter -from mealie.schema.user import CreateToken, LoingLiveTokenIn, LongLiveTokenInDB, PrivateUser +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.routers import UserAPIRouter +from mealie.schema.user import CreateToken, LoingLiveTokenIn, LongLiveTokenInDB -router = UserAPIRouter() +router = UserAPIRouter(prefix="/users", tags=["Users: Tokens"]) -@router.post("/api-tokens", status_code=status.HTTP_201_CREATED) -async def create_api_token( - token_name: LoingLiveTokenIn, - current_user: PrivateUser = Depends(get_current_user), - session: Session = Depends(generate_session), -): - """Create api_token in the Database""" +@controller(router) +class UserApiTokensController(BaseUserController): + @router.post("/api-tokens", status_code=status.HTTP_201_CREATED) + def create_api_token( + self, + token_name: LoingLiveTokenIn, + ): + """Create api_token in the Database""" - token_data = {"long_token": True, "id": str(current_user.id)} + token_data = {"long_token": True, "id": str(self.user.id)} - five_years = timedelta(1825) - token = create_access_token(token_data, five_years) + five_years = timedelta(1825) + token = create_access_token(token_data, five_years) - token_model = CreateToken( - name=token_name.name, - token=token, - user_id=current_user.id, - ) + token_model = CreateToken( + name=token_name.name, + token=token, + user_id=self.user.id, + ) - db = get_repositories(session) + new_token_in_db = self.repos.api_tokens.create(token_model) - new_token_in_db = db.api_tokens.create(token_model) + if new_token_in_db: + return {"token": token} - if new_token_in_db: - return {"token": token} + @router.delete("/api-tokens/{token_id}") + def delete_api_token(self, token_id: int): + """Delete api_token from the Database""" + token: LongLiveTokenInDB = self.repos.api_tokens.get(token_id) + if not token: + raise HTTPException(status.HTTP_404_NOT_FOUND, f"Could not locate token with id '{token_id}' in database") -@router.delete("/api-tokens/{token_id}") -async def delete_api_token( - token_id: int, - current_user: PrivateUser = Depends(get_current_user), - session: Session = Depends(generate_session), -): - """Delete api_token from the Database""" - db = get_repositories(session) - token: LongLiveTokenInDB = db.api_tokens.get(token_id) - - if not token: - raise HTTPException(status.HTTP_404_NOT_FOUND, f"Could not locate token with id '{token_id}' in database") - - if token.user.email == current_user.email: - deleted_token = db.api_tokens.delete(token_id) - return {"token_delete": deleted_token.name} - else: - raise HTTPException(status.HTTP_403_FORBIDDEN) + if token.user.email == self.user.email: + deleted_token = self.repos.api_tokens.delete(token_id) + return {"token_delete": deleted_token.name} + else: + raise HTTPException(status.HTTP_403_FORBIDDEN) diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py index 64a9858e953f..1c30702f1107 100644 --- a/mealie/routes/users/crud.py +++ b/mealie/routes/users/crud.py @@ -1,100 +1,79 @@ -from fastapi import BackgroundTasks, Depends, HTTPException, status +from fastapi import HTTPException, status from pydantic import UUID4 -from sqlalchemy.orm.session import Session from mealie.core import security -from mealie.core.dependencies import get_current_user -from mealie.core.security import hash_password -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import AdminAPIRouter, UserAPIRouter +from mealie.core.security import hash_password, verify_password +from mealie.routes._base import BaseAdminController, controller +from mealie.routes._base.abc_controller import BaseUserController +from mealie.routes._base.mixins import CrudMixins +from mealie.routes._base.routers import AdminAPIRouter, UserAPIRouter from mealie.routes.users._helpers import assert_user_change_allowed -from mealie.schema.user import PrivateUser, UserBase, UserIn, UserOut -from mealie.services.events import create_user_event +from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut -user_router = UserAPIRouter(prefix="") -admin_router = AdminAPIRouter(prefix="") +user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"]) +admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"]) -@admin_router.get("", response_model=list[UserOut]) -async def get_all_users(session: Session = Depends(generate_session)): - db = get_repositories(session) - return db.users.get_all() +@controller(admin_router) +class AdminUserController(BaseAdminController): + @property + def mixins(self) -> CrudMixins: + return CrudMixins[UserIn, UserOut, UserBase](self.repos.users, self.deps.logger) + + @admin_router.get("", response_model=list[UserOut]) + def get_all_users(self): + return self.repos.users.get_all() + + @admin_router.post("", response_model=UserOut, status_code=201) + def create_user(self, new_user: UserIn): + new_user.password = hash_password(new_user.password) + return self.mixins.create_one(new_user) + + @admin_router.get("/{item_id}", response_model=UserOut) + def get_user(self, item_id: UUID4): + return self.mixins.get_one(item_id) + + @admin_router.delete("/{item_id}") + def delete_user(self, item_id: UUID4): + """Removes a user from the database. Must be the current user or a super user""" + + assert_user_change_allowed(item_id, self.user) + + if item_id == 1: # TODO: identify super_user + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPER_USER") + + self.mixins.delete_one(item_id) -@admin_router.post("", response_model=UserOut, status_code=201) -async def create_user( - background_tasks: BackgroundTasks, - new_user: UserIn, - current_user: PrivateUser = Depends(get_current_user), - session: Session = Depends(generate_session), -): +@controller(user_router) +class UserController(BaseUserController): + @user_router.get("/self", response_model=UserOut) + def get_logged_in_user(self): + return self.user - new_user.password = hash_password(new_user.password) - background_tasks.add_task( - create_user_event, "User Created", f"Created by {current_user.full_name}", session=session - ) + @user_router.put("/{item_id}") + def update_user(self, item_id: UUID4, new_data: UserBase): + assert_user_change_allowed(item_id, self.user) - db = get_repositories(session) - return db.users.create(new_user.dict()) + if not self.user.admin and (new_data.admin or self.user.group != new_data.group): + # prevent a regular user from doing admin tasks on themself + raise HTTPException(status.HTTP_403_FORBIDDEN) + if self.user.id == item_id and self.user.admin and not new_data.admin: + # prevent an admin from demoting themself + raise HTTPException(status.HTTP_403_FORBIDDEN) -@admin_router.get("/{id}", response_model=UserOut) -async def get_user(id: UUID4, session: Session = Depends(generate_session)): - db = get_repositories(session) - return db.users.get(id) + self.repos.users.update(item_id, new_data.dict()) + if self.user.id == item_id: + access_token = security.create_access_token(data=dict(sub=new_data.email)) + return {"access_token": access_token, "token_type": "bearer"} -@admin_router.delete("/{id}") -def delete_user( - id: UUID4, - background_tasks: BackgroundTasks, - session: Session = Depends(generate_session), - current_user: PrivateUser = Depends(get_current_user), -): - """Removes a user from the database. Must be the current user or a super user""" + @user_router.put("/{item_id}/password") + def update_password(self, password_change: ChangePassword): + """Resets the User Password""" + if not verify_password(password_change.current_password, self.user.password): + raise HTTPException(status.HTTP_400_BAD_REQUEST) - assert_user_change_allowed(id, current_user) - - if id == 1: # TODO: identify super_user - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPER_USER") - - try: - db = get_repositories(session) - db.users.delete(id) - background_tasks.add_task(create_user_event, "User Deleted", f"User ID: {id}", session=session) - except Exception: - raise HTTPException(status.HTTP_400_BAD_REQUEST) - - -@user_router.get("/self", response_model=UserOut) -async def get_logged_in_user( - current_user: PrivateUser = Depends(get_current_user), -): - return current_user.dict() - - -@user_router.put("/{id}") -async def update_user( - id: UUID4, - new_data: UserBase, - current_user: PrivateUser = Depends(get_current_user), - session: Session = Depends(generate_session), -): - - assert_user_change_allowed(id, current_user) - - if not current_user.admin and (new_data.admin or current_user.group != new_data.group): - # prevent a regular user from doing admin tasks on themself - raise HTTPException(status.HTTP_403_FORBIDDEN) - - if current_user.id == id and current_user.admin and not new_data.admin: - # prevent an admin from demoting themself - raise HTTPException(status.HTTP_403_FORBIDDEN) - - db = get_repositories(session) - db.users.update(id, new_data.dict()) - - if current_user.id == id: - access_token = security.create_access_token(data=dict(sub=new_data.email)) - return {"access_token": access_token, "token_type": "bearer"} + self.user.password = hash_password(password_change.new_password) + return self.repos.users.update_password(self.user.id, self.user.password) diff --git a/mealie/routes/users/favorites.py b/mealie/routes/users/favorites.py index 7d8d99dceb21..e80fd955075f 100644 --- a/mealie/routes/users/favorites.py +++ b/mealie/routes/users/favorites.py @@ -1,48 +1,31 @@ -from fastapi import Depends -from sqlalchemy.orm.session import Session +from pydantic import UUID4 -from mealie.core.dependencies import get_current_user -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import UserAPIRouter +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.routers import UserAPIRouter from mealie.routes.users._helpers import assert_user_change_allowed -from mealie.schema.user import PrivateUser, UserFavorites +from mealie.schema.user import UserFavorites -user_router = UserAPIRouter() +router = UserAPIRouter() -@user_router.get("/{id}/favorites", response_model=UserFavorites) -async def get_favorites(id: str, session: Session = Depends(generate_session)): - """Get user's favorite recipes""" - db = get_repositories(session) - return db.users.get(id, override_schema=UserFavorites) +@controller(router) +class UserFavoritesController(BaseUserController): + @router.get("/{id}/favorites", response_model=UserFavorites) + async def get_favorites(self, id: UUID4): + """Get user's favorite recipes""" + return self.repos.users.get(id, override_schema=UserFavorites) + @router.post("/{id}/favorites/{slug}") + def add_favorite(self, id: UUID4, slug: str): + """Adds a Recipe to the users favorites""" + assert_user_change_allowed(id, self.user) + self.user.favorite_recipes.append(slug) + self.repos.users.update(self.user.id, self.user) -@user_router.post("/{id}/favorites/{slug}") -def add_favorite( - slug: str, - current_user: PrivateUser = Depends(get_current_user), - session: Session = Depends(generate_session), -): - """Adds a Recipe to the users favorites""" - - current_user.favorite_recipes.append(slug) - db = get_repositories(session) - db.users.update(current_user.id, current_user) - - -@user_router.delete("/{id}/favorites/{slug}") -def remove_favorite( - slug: str, - current_user: PrivateUser = Depends(get_current_user), - session: Session = Depends(generate_session), -): - """Adds a Recipe to the users favorites""" - - assert_user_change_allowed(id, current_user) - current_user.favorite_recipes = [x for x in current_user.favorite_recipes if x != slug] - - db = get_repositories(session) - db.users.update(current_user.id, current_user) - - return + @router.delete("/{id}/favorites/{slug}") + def remove_favorite(self, id: UUID4, slug: str): + """Adds a Recipe to the users favorites""" + assert_user_change_allowed(id, self.user) + self.user.favorite_recipes = [x for x in self.user.favorite_recipes if x != slug] + self.repos.users.update(self.user.id, self.user) + return diff --git a/mealie/routes/users/forgot_password.py b/mealie/routes/users/forgot_password.py new file mode 100644 index 000000000000..096ee2d42328 --- /dev/null +++ b/mealie/routes/users/forgot_password.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm.session import Session + +from mealie.db.db_setup import generate_session +from mealie.schema.user.user_passwords import ForgotPassword, ResetPassword +from mealie.services.user_services.password_reset_service import PasswordResetService + +router = APIRouter(prefix="") + + +@router.post("/forgot-password") +def forgot_password(email: ForgotPassword, session: Session = Depends(generate_session)): + """Sends an email with a reset link to the user""" + f_service = PasswordResetService(session) + return f_service.send_reset_email(email.email) + + +@router.post("/reset-password") +def reset_password(reset_password: ResetPassword, session: Session = Depends(generate_session)): + """Resets the user password""" + f_service = PasswordResetService(session) + return f_service.reset_password(reset_password.token, reset_password.password) diff --git a/mealie/routes/users/images.py b/mealie/routes/users/images.py index daf713c02df4..e033e3d8018b 100644 --- a/mealie/routes/users/images.py +++ b/mealie/routes/users/images.py @@ -2,48 +2,41 @@ import shutil from pathlib import Path from fastapi import Depends, File, HTTPException, UploadFile, status -from fastapi.routing import APIRouter from pydantic import UUID4 -from sqlalchemy.orm.session import Session from mealie import utils -from mealie.core.dependencies import get_current_user from mealie.core.dependencies.dependencies import temporary_dir -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import UserAPIRouter +from mealie.routes._base import BaseUserController, controller +from mealie.routes._base.routers import UserAPIRouter from mealie.routes.users._helpers import assert_user_change_allowed from mealie.schema.user import PrivateUser from mealie.services.image import minify -public_router = APIRouter(prefix="", tags=["Users: Images"]) -user_router = UserAPIRouter(prefix="", tags=["Users: Images"]) +router = UserAPIRouter(prefix="", tags=["Users: Images"]) -@user_router.post("/{id}/image") -def update_user_image( - id: UUID4, - profile: UploadFile = File(...), - temp_dir: Path = Depends(temporary_dir), - current_user: PrivateUser = Depends(get_current_user), - session: Session = Depends(generate_session), -): - """Updates a User Image""" - assert_user_change_allowed(id, current_user) +@controller(router) +class UserImageController(BaseUserController): + @router.post("/{id}/image") + def update_user_image( + self, + id: UUID4, + profile: UploadFile = File(...), + temp_dir: Path = Depends(temporary_dir), + ): + """Updates a User Image""" + assert_user_change_allowed(id, self.user) + temp_img = temp_dir.joinpath(profile.filename) - temp_img = temp_dir.joinpath(profile.filename) + with temp_img.open("wb") as buffer: + shutil.copyfileobj(profile.file, buffer) - with temp_img.open("wb") as buffer: - shutil.copyfileobj(profile.file, buffer) + image = minify.to_webp(temp_img) + dest = PrivateUser.get_directory(id) / "profile.webp" - image = minify.to_webp(temp_img) - dest = PrivateUser.get_directory(id) / "profile.webp" + shutil.copyfile(image, dest) - shutil.copyfile(image, dest) + self.repos.users.patch(id, {"cache_key": utils.new_cache_key()}) - db = get_repositories(session) - - db.users.patch(id, {"cache_key": utils.new_cache_key()}) - - if not dest.is_file: - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) + if not dest.is_file: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/mealie/routes/users/passwords.py b/mealie/routes/users/passwords.py deleted file mode 100644 index 2e09b078e05a..000000000000 --- a/mealie/routes/users/passwords.py +++ /dev/null @@ -1,44 +0,0 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.orm.session import Session - -from mealie.core.config import get_app_settings -from mealie.core.security import hash_password -from mealie.db.db_setup import generate_session -from mealie.repos.all_repositories import get_repositories -from mealie.routes.routers import UserAPIRouter -from mealie.schema.user import ChangePassword -from mealie.schema.user.user_passwords import ForgotPassword, ResetPassword -from mealie.services.user_services import UserService -from mealie.services.user_services.password_reset_service import PasswordResetService - -user_router = UserAPIRouter(prefix="") -public_router = APIRouter(prefix="") -settings = get_app_settings() - - -@user_router.put("/{id}/reset-password") -async def reset_user_password(id: int, session: Session = Depends(generate_session)): - new_password = hash_password(settings.DEFAULT_PASSWORD) - - db = get_repositories(session) - db.users.update_password(id, new_password) - - -@user_router.put("/{item_id}/password") -def update_password(password_change: ChangePassword, user_service: UserService = Depends(UserService.write_existing)): - """Resets the User Password""" - return user_service.change_password(password_change) - - -@public_router.post("/forgot-password") -def forgot_password(email: ForgotPassword, session: Session = Depends(generate_session)): - """Sends an email with a reset link to the user""" - f_service = PasswordResetService(session) - return f_service.send_reset_email(email.email) - - -@public_router.post("/reset-password") -def reset_password(reset_password: ResetPassword, session: Session = Depends(generate_session)): - """Resets the user password""" - f_service = PasswordResetService(session) - return f_service.reset_password(reset_password.token, reset_password.password) diff --git a/mealie/routes/users/registration.py b/mealie/routes/users/registration.py index e194aeb1676c..ab1dfa8a3f74 100644 --- a/mealie/routes/users/registration.py +++ b/mealie/routes/users/registration.py @@ -1,5 +1,7 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, status +from mealie.repos.all_repositories import get_repositories +from mealie.routes._base import BasePublicController, controller from mealie.schema.user.registration import CreateUserRegistration from mealie.schema.user.user import UserOut from mealie.services.user_services.registration_service import RegistrationService @@ -7,8 +9,9 @@ from mealie.services.user_services.registration_service import RegistrationServi router = APIRouter(prefix="/register") -@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED) -def register_new_user( - data: CreateUserRegistration, registration_service: RegistrationService = Depends(RegistrationService.public) -): - return registration_service.register_user(data) +@controller(router) +class RegistrationController(BasePublicController): + @router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED) + def register_new_user(self, data: CreateUserRegistration): + registration_service = RegistrationService(self.deps.logger, get_repositories(self.deps.session)) + return registration_service.register_user(data) diff --git a/mealie/schema/admin/about.py b/mealie/schema/admin/about.py index 925461347b7f..df1cdb180732 100644 --- a/mealie/schema/admin/about.py +++ b/mealie/schema/admin/about.py @@ -30,3 +30,4 @@ class CheckAppConfig(CamelModel): email_ready: bool = False ldap_ready: bool = False base_url_set: bool = False + is_up_to_date: bool = False diff --git a/mealie/schema/admin/backup.py b/mealie/schema/admin/backup.py index 2823410af282..93bc62a1670b 100644 --- a/mealie/schema/admin/backup.py +++ b/mealie/schema/admin/backup.py @@ -12,50 +12,18 @@ class BackupOptions(BaseModel): users: bool = True notifications: bool = True - class Config: - schema_extra = { - "example": { - "recipes": True, - "settings": True, - "themes": True, - "groups": True, - "users": True, - } - } - class ImportJob(BackupOptions): name: str force: bool = False rebase: bool = False - class Config: - schema_extra = { - "example": { - "name": "my_local_backup.zip", - "recipes": True, - "settings": True, - "themes": True, - "groups": True, - "users": True, - } - } - class CreateBackup(BaseModel): tag: Optional[str] options: BackupOptions templates: Optional[List[str]] - class Config: - schema_extra = { - "example": { - "tag": "July 23rd 2021", - "options": BackupOptions(), - "template": ["recipes.md"], - } - } - class BackupFile(BaseModel): name: str @@ -66,16 +34,3 @@ class BackupFile(BaseModel): class AllBackups(BaseModel): imports: List[BackupFile] templates: List[str] - - class Config: - schema_extra = { - "example": { - "imports": [ - { - "name": "AutoBackup_12-1-2020.zip", - "date": datetime.now(), - } - ], - "templates": ["recipes.md", "custom_template.md"], - } - } diff --git a/mealie/schema/admin/migration.py b/mealie/schema/admin/migration.py index 97391c48de2e..61081e6a1037 100644 --- a/mealie/schema/admin/migration.py +++ b/mealie/schema/admin/migration.py @@ -9,13 +9,6 @@ from .restore import RecipeImport class ChowdownURL(BaseModel): url: str - class Config: - schema_extra = { - "example": { - "url": "https://chowdownrepo.com/repo", - } - } - class MigrationFile(BaseModel): name: str diff --git a/mealie/schema/cookbook/cookbook.py b/mealie/schema/cookbook/cookbook.py index d75f4893e42d..9b602e9aa0f7 100644 --- a/mealie/schema/cookbook/cookbook.py +++ b/mealie/schema/cookbook/cookbook.py @@ -1,7 +1,5 @@ -from uuid import UUID - from fastapi_camelcase import CamelModel -from pydantic import validator +from pydantic import UUID4, validator from slugify import slugify from ..recipe.recipe_category import CategoryBase, RecipeCategoryResponse @@ -25,16 +23,16 @@ class CreateCookBook(CamelModel): return slug -class UpdateCookBook(CreateCookBook): +class SaveCookBook(CreateCookBook): + group_id: UUID4 + + +class UpdateCookBook(SaveCookBook): id: int -class SaveCookBook(CreateCookBook): - group_id: UUID - - class ReadCookBook(UpdateCookBook): - group_id: UUID + group_id: UUID4 categories: list[CategoryBase] = [] class Config: @@ -42,7 +40,7 @@ class ReadCookBook(UpdateCookBook): class RecipeCookBook(ReadCookBook): - group_id: UUID + group_id: UUID4 categories: list[RecipeCategoryResponse] class Config: diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index c2f38fb01401..516d3a111d71 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import datetime from pathlib import Path from typing import Any, Optional diff --git a/mealie/schema/response/__init__.py b/mealie/schema/response/__init__.py index 343a0c52c402..95d4cd1fb2fb 100644 --- a/mealie/schema/response/__init__.py +++ b/mealie/schema/response/__init__.py @@ -1,2 +1,2 @@ # GENERATED CODE - DO NOT MODIFY BY HAND -from .error_response import * +from .responses import * diff --git a/mealie/schema/response/error_response.py b/mealie/schema/response/responses.py similarity index 58% rename from mealie/schema/response/error_response.py rename to mealie/schema/response/responses.py index 3eb5f5d33e7e..d145ee37d7be 100644 --- a/mealie/schema/response/error_response.py +++ b/mealie/schema/response/responses.py @@ -15,3 +15,16 @@ class ErrorResponse(BaseModel): in the same call, for use while providing details to a HTTPException """ return cls(message=message, exception=exception).dict() + + +class SuccessResponse(BaseModel): + message: str + error: bool = False + + @classmethod + def respond(cls, message: str) -> dict: + """ + This method is an helper to create an obect and convert to a dictionary + in the same call, for use while providing details to a HTTPException + """ + return cls(message=message).dict() diff --git a/mealie/schema/static/__init__.py b/mealie/schema/static/__init__.py new file mode 100644 index 000000000000..d4f3b74d1078 --- /dev/null +++ b/mealie/schema/static/__init__.py @@ -0,0 +1 @@ +from .recipe_keys import * diff --git a/mealie/schema/static/recipe_keys.py b/mealie/schema/static/recipe_keys.py new file mode 100644 index 000000000000..106dbe74a57a --- /dev/null +++ b/mealie/schema/static/recipe_keys.py @@ -0,0 +1,136 @@ +cook_time = "cookTime" +cooking_method = "cookingMethod" +nutrition = "nutrition" +recipe_category = "recipeCategory" +recipe_cuisine = "recipeCuisine" +recipe_ingredient = "recipeIngredient" +recipe_instructions = "recipeInstructions" +recipe_yield = "recipeYield" +suitable_for_diet = "suitableForDiet" +estimated_cost = "estimatedCost" +perform_time = "performTime" +prep_time = "prepTime" +step = "step" +supply = "supply" +tool = "tool" +total_time = "totalTime" +y_yield = "yield" +about = "about" +abstract = "abstract" +access_mode = "accessMode" +access_mode_sufficient = "accessModeSufficient" +accessibility_api = "accessibilityAPI" +accessibility_control = "accessibilityControl" +accessibility_feature = "accessibilityFeature" +accessibility_hazard = "accessibilityHazard" +accessibility_summary = "accessibilitySummary" +accountable_person = "accountablePerson" +acquire_license_page = "acquireLicensePage" +aggregate_rating = "aggregateRating" +alternative_headline = "alternativeHeadline" +archived_at = "archivedAt" +assesses = "assesses" +associated_media = "associatedMedia" +audience = "audience" +audio = "audio" +author = "author" +award = "award" +character = "character" +citation = "citation" +comment = "comment" +comment_count = "commentCount" +conditions_of_access = "conditionsOfAccess" +content_location = "contentLocation" +content_rating = "contentRating" +content_reference_time = "contentReferenceTime" +contributor = "contributor" +copyright_holder = "copyrightHolder" +copyright_notice = "copyrightNotice" +copyright_year = "copyrightYear" +correction = "correction" +country_of_origin = "countryOfOrigin" +creative_work_status = "creativeWorkStatus" +creator = "creator" +credit_text = "creditText" +date_created = "dateCreated" +date_created = "dateCreated" +date_modified = "dateModified" +date_published = "datePublished" +discussion_url = "discussionUrl" +edit_eidr = "editEIDR" +editor = "editor" +educational_alignment = "educationalAlignment" +educational_level = "educationalLevel" +educational_use = "educationalUse" +encoding = "encoding" +encoding_format = "encodingFormat" +example_of_work = "exampleOfWork" +expires = "expires" +funder = "funder" +genre = "genre" +has_part = "hasPart" +headline = "headline" +in_language = "inLanguage" +interaction_statistic = "interactionStatistic" +interactivity_type = "interactivityType" +interpreted_as_claim = "interpretedAsClaim" +is_accessible_for_free = "isAccessibleForFree" +is_based_on = "isBasedOn" +is_family_friendly = "isFamilyFriendly" +is_part_of = "isPartOf" +keywords = "keywords" +learning_resource_type = "learningResourceType" +license = "license" +location_created = "locationCreated" +main_entity = "mainEntity" +maintainer = "maintainer" +material = "material" +material_extent = "materialExtent" +mentions = "mentions" +offers = "offers" +pattern = "pattern" +position = "position" +producer = "producer" +provider = "provider" +publication = "publication" +publisher = "publisher" +publisher_imprint = "publisherImprint" +publishing_principles = "publishingPrinciples" +recorded_at = "recordedAt" +released_event = "releasedEvent" +review = "review" +schema_version = "schemaVersion" +sd_date_published = "sdDatePublished" +sd_license = "sdLicense" +sd_publisher = "sdPublisher" +size = "size" +source_organization = "sourceOrganization" +spatial = "spatial" +spatial_coverage = "spatialCoverage" +sponsor = "sponsor" +teaches = "teaches" +temporal = "temporal" +temporal_coverage = "temporalCoverage" +text = "text" +thumbnail_url = "thumbnailUrl" +time_required = "timeRequired" +translation_of_work = "translationOfWork" +translator = "translator" +typical_age_range = "typicalAgeRange" +usage_info = "usageInfo" +version = "version" +video = "video" +work_example = "workExample" +work_translation = "workTranslation" +additional_type = "additionalType" +alternate_name = "alternateName" +description = "description" +disambiguating_description = "disambiguatingDescription" +identifier = "identifier" +image = "image" +main_entity_of_page = "mainEntityOfPage" +name = "name" +potential_action = "potentialAction" +same_as = "sameAs" +subject_of = "subjectOf" +url = "url" diff --git a/mealie/services/_base_http_service/__init__.py b/mealie/services/_base_http_service/__init__.py deleted file mode 100644 index 94e328e00d18..000000000000 --- a/mealie/services/_base_http_service/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .http_services import * -from .router_factory import * diff --git a/mealie/services/_base_http_service/base_http_service.py b/mealie/services/_base_http_service/base_http_service.py deleted file mode 100644 index 296ca649f788..000000000000 --- a/mealie/services/_base_http_service/base_http_service.py +++ /dev/null @@ -1,156 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Callable, Generic, Type, TypeVar - -from fastapi import BackgroundTasks, Depends, HTTPException, status -from pydantic import BaseModel -from sqlalchemy.orm.session import Session - -from mealie.core.config import get_app_dirs, get_app_settings -from mealie.core.root_logger import get_logger -from mealie.db.db_setup import SessionLocal -from mealie.lang import get_locale_provider -from mealie.repos.all_repositories import get_repositories -from mealie.schema.user.user import PrivateUser - -logger = get_logger() - -T = TypeVar("T") -D = TypeVar("D") - - -CLS_DEP = TypeVar("CLS_DEP") # Generic Used for the class method dependencies - - -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`: Item returned from database layer - """ - - item: D = None - - # Function that Generate Corrsesponding Routes through RouterFactory: - # if the method is defined or != `None` than the corresponding route is defined through the RouterFactory. - # If the method is not defined, then the route will be excluded from creation. This service based articheture - # is being adopted as apart of the v1 migration - 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 - - # 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 - self.logged_in = bool(self.user) - self.background_tasks = background_tasks - - # Static Globals Dependency Injection - self.db = get_repositories(session) - self.app_dirs = get_app_dirs() - self.settings = get_app_settings() - self.t = get_locale_provider().t - - def _existing_factory(dependency: Type[CLS_DEP]) -> classmethod: - def cls_method(cls, item_id: T, deps: CLS_DEP = Depends(dependency)): - new_class = cls(session=deps.session, user=deps.user, background_tasks=deps.bg_task) - new_class.assert_existing(item_id) - return new_class - - return classmethod(cls_method) - - def _class_method_factory(dependency: Type[CLS_DEP]) -> classmethod: - def cls_method(cls, deps: CLS_DEP = Depends(dependency)): - return cls(session=deps.session, user=deps.user, background_tasks=deps.bg_task) - - return classmethod(cls_method) - - @classmethod - @abstractmethod - def public(cls, deps: Any): - pass - - @classmethod - @abstractmethod - def private(cls, deps: Any): - pass - - @classmethod - @abstractmethod - def read_existing(cls, deps: Any): - pass - - @classmethod - @abstractmethod - def write_existing(cls, deps: Any): - pass - - @abstractmethod - def populate_item(self) -> None: - pass - - @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_one(self.user.group, "name") - self._group_id_cache = group.id - return self._group_id_cache - - def cast(self, item: BaseModel, dest, assign_owner=True) -> T: - """cast a pydantic model to the destination type - - Args: - item (BaseModel): A pydantic model containing data - dest ([type]): A type to cast the data to - assign_owner (bool, optional): If true, will assign the user_id and group_id to the dest type. Defaults to True. - - Returns: - TypeVar(dest): Returns the destionation model type - """ - data = item.dict() - - if assign_owner: - data["user_id"] = self.user.id - data["group_id"] = self.group_id - - return dest(**data) - - 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) - - if hasattr(self, "check_item"): - self.check_item() - - 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/crud_http_mixins.py b/mealie/services/_base_http_service/crud_http_mixins.py deleted file mode 100644 index 3fc0835b16ee..000000000000 --- a/mealie/services/_base_http_service/crud_http_mixins.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Generic, TypeVar - -from fastapi import HTTPException, status -from pydantic import BaseModel -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session - -from mealie.core.root_logger import get_logger -from mealie.repos.repository_generic import RepositoryGeneric -from mealie.schema.response import ErrorResponse - -C = TypeVar("C", bound=BaseModel) -R = TypeVar("R", bound=BaseModel) -U = TypeVar("U", bound=BaseModel) -DAL = TypeVar("DAL", bound=RepositoryGeneric) -logger = get_logger() - - -class CrudHttpMixins(Generic[C, R, U], ABC): - item: R - session: Session - - @property - @abstractmethod - def repo(self) -> DAL: - ... - - def populate_item(self, id: int) -> R: - self.item = self.repo.get_one(id) - return self.item - - def _create_one(self, data: C, default_msg="generic-create-error", exception_msgs: dict | None = None) -> R: - try: - self.item = self.repo.create(data) - except Exception as ex: - logger.exception(ex) - self.session.rollback() - - msg = default_msg - if exception_msgs: - msg = exception_msgs.get(type(ex), default_msg) - - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail=ErrorResponse( - message=msg, - exception=str(ex), - ).dict(), - ) - - return self.item - - def _update_one(self, data: U, item_id: int = None) -> R: - if not self.item: - return - - target_id = item_id or self.item.id - self.item = self.repo.update(target_id, data) - - return self.item - - def _patch_one(self, data: U, item_id: int) -> None: - try: - self.item = self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True)) - except IntegrityError: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": "generic-patch-error"}) - - def _delete_one(self, item_id: int = None) -> R: - target_id = item_id or self.item.id - logger.info(f"Deleting item with id {target_id}") - - try: - self.item = self.repo.delete(target_id) - except Exception as ex: - logger.exception(ex) - raise HTTPException( - status.HTTP_400_BAD_REQUEST, detail={"message": "generic-delete-error", "exception": str(ex)} - ) - - return self.item diff --git a/mealie/services/_base_http_service/http_services.py b/mealie/services/_base_http_service/http_services.py deleted file mode 100644 index cc6d1fd4a005..000000000000 --- a/mealie/services/_base_http_service/http_services.py +++ /dev/null @@ -1,60 +0,0 @@ -from abc import abstractmethod -from typing import TypeVar - -from mealie.core.dependencies.grouped import AdminDeps, PublicDeps, UserDeps - -from .base_http_service import BaseHttpService - -T = TypeVar("T") -D = TypeVar("D") - - -class PublicHttpService(BaseHttpService[T, D]): - """ - PublicHttpService sets the class methods to PublicDeps for read actions - and UserDeps for write actions which are inaccessible to not logged in users. - """ - - read_existing = BaseHttpService._existing_factory(PublicDeps) - write_existing = BaseHttpService._existing_factory(UserDeps) - - public = BaseHttpService._class_method_factory(PublicDeps) - private = BaseHttpService._class_method_factory(UserDeps) - - @abstractmethod - def populate_item(self) -> None: - ... - - -class UserHttpService(BaseHttpService[T, D]): - """ - UserHttpService sets the class methods to UserDeps which are inaccessible - to not logged in users. - """ - - read_existing = BaseHttpService._existing_factory(UserDeps) - write_existing = BaseHttpService._existing_factory(UserDeps) - - public = BaseHttpService._class_method_factory(UserDeps) - private = BaseHttpService._class_method_factory(UserDeps) - - @abstractmethod - def populate_item(self) -> None: - ... - - -class AdminHttpService(BaseHttpService[T, D]): - """ - AdminHttpService restricts the class methods to AdminDeps which are restricts - all class methods to users who are administrators. - """ - - read_existing = BaseHttpService._existing_factory(AdminDeps) - write_existing = BaseHttpService._existing_factory(AdminDeps) - - public = BaseHttpService._class_method_factory(AdminDeps) - private = BaseHttpService._class_method_factory(AdminDeps) - - @abstractmethod - def populate_item(self) -> None: - ... diff --git a/mealie/services/_base_http_service/router_factory.py b/mealie/services/_base_http_service/router_factory.py deleted file mode 100644 index fb1ca5fc114b..000000000000 --- a/mealie/services/_base_http_service/router_factory.py +++ /dev/null @@ -1,214 +0,0 @@ -import inspect -from typing import Any, Callable, Optional, Sequence, Type, TypeVar, get_type_hints - -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]] - - -def get_return(func: Callable, default) -> Type: - return get_type_hints(func).get("return", default) - - -def get_func_args(func: Callable) -> Sequence[str]: - for _, value in get_type_hints(func).items(): - if value: - return value - else: - return None - - -class RouterFactory(APIRouter): - - schema: Type[T] - _base_path: str = "/" - - def __init__(self, service: Type[S], prefix: Optional[str] = None, tags: Optional[list[str]] = None, *_, **kwargs): - """ - RouterFactory takes a concrete service class derived from the BaseHttpService class and returns common - CRUD Routes for the service. The following features are implmeneted in the RouterFactory: - - 1. API endpoint Descriptions are read from the docstrings of the methods in the passed in service class - 2. Return types are inferred from the concrete service schema, or specified from the return type annotations. - This provides flexibility to return different types based on each route depending on client needs. - 3. Arguemnt types are inferred for Post and Put routes where the first type annotated argument is the data that - is beging posted or updated. Note that this is only done for the first argument of the method. - 4. The Get and Delete routes assume that you've defined the `write_existing` and `read_existing` methods in the - service class. The dependencies defined in the `write_existing` and `read_existing` methods are passed directly - to the FastAPI router and as such should include the `item_id` or equilivent argument. - """ - self.service: Type[S] = service - self.schema: Type[T] = service._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", - description=inspect.cleandoc(self.service.get_all.__doc__ or ""), - ) - - if self.service.create_one: - self._add_api_route( - "", - self._create(), - methods=["POST"], - response_model=self.schema, - summary="Create One", - status_code=201, - description=inspect.cleandoc(self.service.create_one.__doc__ or ""), - ) - - 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", - description=inspect.cleandoc(self.service.update_many.__doc__ or ""), - ) - - 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", - description=inspect.cleandoc(self.service.delete_all.__doc__ or ""), - ) - - if self.service.populate_item: - self._add_api_route( - "/{item_id}", - self._get_one(), - methods=["GET"], - response_model=get_type_hints(self.service.populate_item).get("return", self.schema), - summary="Get One", - description=inspect.cleandoc(self.service.populate_item.__doc__ or ""), - ) - - if self.service.update_one: - self._add_api_route( - "/{item_id}", - self._update(), - methods=["PUT"], - response_model=self.schema, - summary="Update One", - description=inspect.cleandoc(self.service.update_one.__doc__ or ""), - ) - - if self.service.delete_one: - self._add_api_route( - "/{item_id}", - self._delete_one(), - methods=["DELETE"], - response_model=self.schema, - summary="Delete One", - description=inspect.cleandoc(self.service.delete_one.__doc__ or ""), - ) - - 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]: - service_dep = getattr(self.service, "get_all_dep", self.service.private) - - def route(service: S = Depends(service_dep)) -> 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]: - create_schema = get_func_args(self.service.create_one) or self.schema - - def route(data: 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]: - update_schema = get_func_args(self.service.update_one) or self.schema - - def route(data: 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]: - update_many_schema = get_func_args(self.service.update_many) or list[self.schema] - - def route(data: update_many_schema, service: S = Depends(self.service.private)) -> 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"] diff --git a/mealie/services/_base_service/__init__.py b/mealie/services/_base_service/__init__.py index ce0a857edf07..3ba7db12a294 100644 --- a/mealie/services/_base_service/__init__.py +++ b/mealie/services/_base_service/__init__.py @@ -1,9 +1,11 @@ from mealie.core.config import get_app_dirs, get_app_settings +from mealie.core.root_logger import get_logger from mealie.lang import get_locale_provider class BaseService: def __init__(self) -> None: - self.app_dirs = get_app_dirs() + self.directories = get_app_dirs() self.settings = get_app_settings() self.t = get_locale_provider() + self.logger = get_logger() diff --git a/mealie/services/admin/__init__.py b/mealie/services/admin/__init__.py deleted file mode 100644 index c2f3d40c2fdd..000000000000 --- a/mealie/services/admin/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .backup_service import * diff --git a/mealie/services/admin/admin_group_service.py b/mealie/services/admin/admin_group_service.py deleted file mode 100644 index 167ee991b56a..000000000000 --- a/mealie/services/admin/admin_group_service.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from functools import cached_property - -from fastapi import HTTPException, status -from pydantic import UUID4 - -from mealie.schema.group.group import GroupAdminUpdate -from mealie.schema.mapper import mapper -from mealie.schema.response import ErrorResponse -from mealie.schema.user.user import GroupBase, GroupInDB -from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins -from mealie.services._base_http_service.http_services import AdminHttpService -from mealie.services.events import create_group_event -from mealie.services.group_services.group_utils import create_new_group - - -class AdminGroupService( - CrudHttpMixins[GroupBase, GroupInDB, GroupAdminUpdate], - AdminHttpService[UUID4, GroupInDB], -): - event_func = create_group_event - _schema = GroupInDB - - @cached_property - def repo(self): - return self.db.groups - - def populate_item(self, id: UUID4) -> GroupInDB: - self.item = self.repo.get_one(id) - return self.item - - def get_all(self) -> list[GroupInDB]: - return self.repo.get_all() - - def create_one(self, data: GroupBase) -> GroupInDB: - return create_new_group(self.db, data) - - def update_one(self, data: GroupAdminUpdate, item_id: UUID4 = None) -> GroupInDB: - target_id = item_id or data.id - - if data.preferences: - preferences = self.db.group_preferences.get_one(value=target_id, key="group_id") - preferences = mapper(data.preferences, preferences) - self.item.preferences = self.db.group_preferences.update(target_id, preferences) - - if data.name not in ["", self.item.name]: - self.item.name = data.name - self.item = self.repo.update(target_id, self.item) - - return self.item - - def delete_one(self, id: UUID4 = None) -> GroupInDB: - target_id = id or self.item.id - - if len(self.item.users) > 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ErrorResponse(message="Cannot delete group with users").dict(), - ) - - return self._delete_one(target_id) diff --git a/mealie/services/admin/admin_user_service.py b/mealie/services/admin/admin_user_service.py deleted file mode 100644 index 9c2b3e226322..000000000000 --- a/mealie/services/admin/admin_user_service.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -from functools import cached_property - -from mealie.schema.user.user import UserIn, UserOut -from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins -from mealie.services._base_http_service.http_services import AdminHttpService -from mealie.services.events import create_user_event - - -class AdminUserService( - CrudHttpMixins[UserOut, UserIn, UserIn], - AdminHttpService[int, UserOut], -): - event_func = create_user_event - _schema = UserOut - - @cached_property - def repo(self): - return self.db.users - - def populate_item(self, id: int) -> UserOut: - self.item = self.repo.get_one(id) - return self.item - - def get_all(self) -> list[UserOut]: - return self.repo.get_all() - - def create_one(self, data: UserIn) -> UserOut: - return self._create_one(data) - - def update_one(self, data: UserOut, item_id: int = None) -> UserOut: - return self._update_one(data, item_id) - - def delete_one(self, id: int = None) -> UserOut: - return self._delete_one(id) diff --git a/mealie/services/admin/backup_service.py b/mealie/services/admin/backup_service.py deleted file mode 100644 index a5fd16d958a8..000000000000 --- a/mealie/services/admin/backup_service.py +++ /dev/null @@ -1,36 +0,0 @@ -import operator - -from mealie.schema.admin.backup import AllBackups, BackupFile, CreateBackup -from mealie.services._base_http_service import AdminHttpService -from mealie.services.events import create_backup_event - -from .exporter import Exporter - - -class BackupHttpService(AdminHttpService): - event_func = create_backup_event - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.exporter = Exporter() - - def get_all(self) -> AllBackups: - imports = [] - for archive in self.app_dirs.BACKUP_DIR.glob("*.zip"): - backup = BackupFile(name=archive.name, date=archive.stat().st_ctime) - imports.append(backup) - - templates = [template.name for template in self.app_dirs.TEMPLATE_DIR.glob("*.*")] - imports.sort(key=operator.attrgetter("date"), reverse=True) - - return AllBackups(imports=imports, templates=templates) - - def create_one(self, options: CreateBackup): - pass - - def delete_one(self): - pass - - -class BackupService: - pass diff --git a/mealie/services/admin/exporter.py b/mealie/services/admin/exporter.py deleted file mode 100644 index 9742dee34d91..000000000000 --- a/mealie/services/admin/exporter.py +++ /dev/null @@ -1,2 +0,0 @@ -class Exporter: - pass diff --git a/mealie/services/admin/import_service.py b/mealie/services/admin/import_service.py deleted file mode 100644 index e0ae870380d3..000000000000 --- a/mealie/services/admin/import_service.py +++ /dev/null @@ -1,21 +0,0 @@ -from mealie.services._base_http_service import AdminHttpService - -from .importer import Importer - - -class ImportHttpService(AdminHttpService): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.exporter = Importer() - - def get_all(self): - pass - - def get_one(self): - pass - - def create(self): - pass - - def delete_one(self): - pass diff --git a/mealie/services/admin/importer.py b/mealie/services/admin/importer.py deleted file mode 100644 index ec96b8fdbef4..000000000000 --- a/mealie/services/admin/importer.py +++ /dev/null @@ -1,2 +0,0 @@ -class Importer: - pass diff --git a/mealie/services/backups/imports.py b/mealie/services/backups/imports.py index 7d3569d36080..0d09a239304c 100644 --- a/mealie/services/backups/imports.py +++ b/mealie/services/backups/imports.py @@ -288,28 +288,15 @@ def import_database( import_settings=True, import_users=True, import_groups=True, - import_notifications=True, force_import: bool = False, - rebase: bool = False, + **_, ): import_session = ImportDatabase(user, session, archive, force_import) - recipe_report = [] - if import_recipes: - recipe_report = import_session.import_recipes() - - settings_report = [] - if import_settings: - settings_report = import_session.import_settings() - - group_report = [] - if import_groups: - group_report = import_session.import_groups() - - user_report = [] - if import_users: - user_report = import_session.import_users() - + recipe_report = import_session.import_recipes() if import_recipes else [] + settings_report = import_session.import_settings() if import_settings else [] + group_report = import_session.import_groups() if import_groups else [] + user_report = import_session.import_users() if import_users else [] notification_report = [] import_session.clean_up() diff --git a/mealie/services/events.py b/mealie/services/events.py index 97265f6d4e09..8252cd8bdaaa 100644 --- a/mealie/services/events.py +++ b/mealie/services/events.py @@ -17,7 +17,7 @@ def create_general_event(title, text, session=None): save_event(title=title, text=text, category=category, session=session) -def create_recipe_event(title, text, session=None, attachment=None): +def create_recipe_event(title, text, session=None, **_): category = EventCategory.recipe save_event(title=title, text=text, category=category, session=session) @@ -27,16 +27,6 @@ def create_backup_event(title, text, session=None): save_event(title=title, text=text, category=category, session=session) -def create_scheduled_event(title, text, session=None): - category = EventCategory.scheduled - save_event(title=title, text=text, category=category, session=session) - - -def create_migration_event(title, text, session=None): - category = EventCategory.migration - save_event(title=title, text=text, category=category, session=session) - - def create_group_event(title, text, session=None): category = EventCategory.group save_event(title=title, text=text, category=category, session=session) diff --git a/mealie/services/group_services/__init__.py b/mealie/services/group_services/__init__.py index 62cb46fac5d0..e69de29bb2d1 100644 --- a/mealie/services/group_services/__init__.py +++ b/mealie/services/group_services/__init__.py @@ -1,3 +0,0 @@ -from .cookbook_service import * -from .group_service import * -from .webhook_service import * diff --git a/mealie/services/group_services/cookbook_service.py b/mealie/services/group_services/cookbook_service.py deleted file mode 100644 index fd889a4ba788..000000000000 --- a/mealie/services/group_services/cookbook_service.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from functools import cached_property - -from mealie.core.root_logger import get_logger -from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook -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_group_event -from mealie.utils.error_messages import ErrorMessages - -logger = get_logger(module=__name__) - - -class CookbookService( - CrudHttpMixins[CreateCookBook, ReadCookBook, UpdateCookBook], - UserHttpService[int, ReadCookBook], -): - event_func = create_group_event - _restrict_by_group = True - _schema = ReadCookBook - - @cached_property - def repo(self): - return self.db.cookbooks - - def populate_item(self, item_id: int) -> RecipeCookBook: - try: - item_id = int(item_id) - except Exception: - pass - - if isinstance(item_id, int): - self.item = self.repo.get_one(item_id, override_schema=RecipeCookBook) - - else: - self.item = self.repo.get_one(item_id, key="slug", override_schema=RecipeCookBook) - - def get_all(self) -> list[ReadCookBook]: - items = self.repo.get(self.group_id, "group_id", limit=999) - items.sort(key=lambda x: x.position) - return items - - def create_one(self, data: CreateCookBook) -> ReadCookBook: - data = self.cast(data, SaveCookBook) - return self._create_one(data, ErrorMessages.cookbook_create_failure) - - def update_one(self, data: UpdateCookBook, id: int = None) -> ReadCookBook: - return self._update_one(data, id) - - def update_many(self, data: list[UpdateCookBook]) -> list[ReadCookBook]: - updated = [] - - for cookbook in data: - cb = self.repo.update(cookbook.id, cookbook) - updated.append(cb) - - return updated - - def delete_one(self, id: int = None) -> ReadCookBook: - return self._delete_one(id) diff --git a/mealie/services/group_services/group_service.py b/mealie/services/group_services/group_service.py deleted file mode 100644 index 29ec2acb6619..000000000000 --- a/mealie/services/group_services/group_service.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -from fastapi import Depends, HTTPException, status - -from mealie.core.dependencies.grouped import UserDeps -from mealie.core.root_logger import get_logger -from mealie.core.security import url_safe_token -from mealie.schema.group.group_permissions import SetPermissions -from mealie.schema.group.group_preferences import UpdateGroupPreferences -from mealie.schema.group.invite_token import EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken -from mealie.schema.recipe.recipe_category import CategoryBase -from mealie.schema.user.user import GroupInDB, PrivateUser, UserOut -from mealie.services._base_http_service.http_services import UserHttpService -from mealie.services.email import EmailService -from mealie.services.events import create_group_event - -logger = get_logger(module=__name__) - - -class GroupSelfService(UserHttpService[int, str]): - _restrict_by_group = False - event_func = create_group_event - item: GroupInDB - - @classmethod - def read_existing(cls, deps: UserDeps = 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: UserDeps = Depends()): - """Override parent method to remove `item_id` from arguments""" - return super().write_existing(item_id=0, deps=deps) - - @classmethod - def manage_existing(cls, deps: UserDeps = Depends()): - """Override parent method to remove `item_id` from arguments""" - if not deps.user.can_manage: - raise HTTPException(status.HTTP_403_FORBIDDEN) - return super().write_existing(item_id=0, deps=deps) - - def populate_item(self, _: str = None) -> GroupInDB: - self.item = self.db.groups.get(self.group_id) - return self.item - - # ==================================================================== - # Manage Menbers - - def get_members(self) -> list[UserOut]: - return self.db.users.multi_query(query_by={"group_id": self.item.id}, override_schema=UserOut) - - def set_member_permissions(self, permissions: SetPermissions) -> PrivateUser: - target_user = self.db.users.get(permissions.user_id) - - if not target_user: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found") - - if target_user.group_id != self.group_id: - raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not a member of this group") - - target_user.can_invite = permissions.can_invite - target_user.can_manage = permissions.can_manage - target_user.can_organize = permissions.can_organize - - return self.db.users.update(permissions.user_id, target_user) - - # ==================================================================== - # Meal Categories - - def update_categories(self, new_categories: list[CategoryBase]): - self.item.categories = new_categories - return self.db.groups.update(self.group_id, self.item) - - # ==================================================================== - # Preferences - - def update_preferences(self, new_preferences: UpdateGroupPreferences): - self.db.group_preferences.update(self.group_id, new_preferences) - return self.populate_item() - - # ==================================================================== - # Group Invites - - def create_invite_token(self, uses: int = 1) -> None: - if not self.user.can_invite: - raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not allowed to create invite tokens") - - token = SaveInviteToken(uses_left=uses, group_id=self.group_id, token=url_safe_token()) - return self.db.group_invite_tokens.create(token) - - def get_invite_tokens(self) -> list[ReadInviteToken]: - return self.db.group_invite_tokens.multi_query({"group_id": self.group_id}) - - def email_invitation(self, invite: EmailInvitation) -> EmailInitationResponse: - email_service = EmailService() - url = f"{self.settings.BASE_URL}/register?token={invite.token}" - - success = False - error = None - try: - success = email_service.send_invitation(address=invite.email, invitation_url=url) - except Exception as e: - error = str(e) - - return EmailInitationResponse(success=success, error=error) - - # ==================================================================== - # Export / Import Recipes diff --git a/mealie/services/group_services/meal_service.py b/mealie/services/group_services/meal_service.py deleted file mode 100644 index 4aed9aa59d20..000000000000 --- a/mealie/services/group_services/meal_service.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from datetime import date -from functools import cached_property - -from mealie.core.root_logger import get_logger -from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry - -from .._base_http_service.crud_http_mixins import CrudHttpMixins -from .._base_http_service.http_services import UserHttpService -from ..events import create_group_event - -logger = get_logger(module=__name__) - - -class MealService(CrudHttpMixins[CreatePlanEntry, ReadPlanEntry, UpdatePlanEntry], UserHttpService[int, ReadPlanEntry]): - event_func = create_group_event - _restrict_by_group = True - - _schema = ReadPlanEntry - item: ReadPlanEntry - - @cached_property - def repo(self): - return self.db.meals - - def populate_item(self, id: int) -> ReadPlanEntry: - self.item = self.repo.get_one(id) - return self.item - - def get_slice(self, start: date = None, end: date = None) -> list[ReadPlanEntry]: - # 2 days ago - return self.repo.get_slice(start, end, group_id=self.group_id) - - def get_today(self) -> list[ReadPlanEntry]: - return self.repo.get_today(group_id=self.group_id) - - def create_one(self, data: CreatePlanEntry) -> ReadPlanEntry: - data = self.cast(data, SavePlanEntry) - return self._create_one(data) - - def update_one(self, data: UpdatePlanEntry, id: int = None) -> ReadPlanEntry: - target_id = id or self.item.id - return self._update_one(data, target_id) - - def delete_one(self, id: int = None) -> ReadPlanEntry: - target_id = id or self.item.id - return self._delete_one(target_id) diff --git a/mealie/services/group_services/migration_service.py b/mealie/services/group_services/migration_service.py deleted file mode 100644 index c8f0d98fe983..000000000000 --- a/mealie/services/group_services/migration_service.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -from functools import cached_property -from pathlib import Path - -from pydantic.types import UUID4 - -from mealie.core.root_logger import get_logger -from mealie.schema.group.group_migration import SupportedMigrations -from mealie.schema.reports.reports import ReportOut, ReportSummary -from mealie.services._base_http_service.http_services import UserHttpService -from mealie.services.events import create_group_event -from mealie.services.migrations import ChowdownMigrator, NextcloudMigrator -from mealie.services.migrations.mealie_alpha import MealieAlphaMigrator -from mealie.services.migrations.paprika import PaprikaMigrator - -logger = get_logger(module=__name__) - - -class GroupMigrationService(UserHttpService[int, ReportOut]): - event_func = create_group_event - _restrict_by_group = True - _schema = ReportOut - - @cached_property - def repo(self): - raise NotImplementedError - - def populate_item(self, _: UUID4) -> ReportOut: - return None - - def migrate(self, migration: SupportedMigrations, add_migration_tag: bool, archive: Path) -> ReportSummary: - args = { - "archive": archive, - "db": self.db, - "session": self.session, - "user_id": self.user.id, - "group_id": self.group_id, - "add_migration_tag": add_migration_tag, - } - - if migration == SupportedMigrations.nextcloud: - self.migration_type = NextcloudMigrator(**args) - - if migration == SupportedMigrations.chowdown: - self.migration_type = ChowdownMigrator(**args) - - if migration == SupportedMigrations.paprika: - self.migration_type = PaprikaMigrator(**args) - - if migration == SupportedMigrations.mealie_alpha: - self.migration_type = MealieAlphaMigrator(**args) - - return self.migration_type.migrate(f"{migration.value.title()} Migration") diff --git a/mealie/services/group_services/reports_service.py b/mealie/services/group_services/reports_service.py deleted file mode 100644 index 65df00de421e..000000000000 --- a/mealie/services/group_services/reports_service.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from functools import cached_property - -from mealie.core.root_logger import get_logger -from mealie.schema.reports.reports import ReportCategory, ReportCreate, ReportOut, ReportSummary -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_group_event - -logger = get_logger(module=__name__) - - -class GroupReportService(CrudHttpMixins[ReportOut, ReportCreate, ReportCreate], UserHttpService[int, ReportOut]): - event_func = create_group_event - _restrict_by_group = True - _schema = ReportOut - - @cached_property - def repo(self): - return self.db.group_reports - - def populate_item(self, id: int) -> ReportOut: - self.item = self.repo.get_one(id) - return self.item - - def _get_all(self, report_type: ReportCategory = None) -> list[ReportSummary]: - return self.repo.multi_query({"group_id": self.group_id, "category": report_type}, limit=9999) - - def delete_one(self, id: int = None) -> ReportOut: - return self._delete_one(id) diff --git a/mealie/services/group_services/shopping_lists.py b/mealie/services/group_services/shopping_lists.py index 28fe7a0ecbd1..8afe3b64abf2 100644 --- a/mealie/services/group_services/shopping_lists.py +++ b/mealie/services/group_services/shopping_lists.py @@ -1,30 +1,17 @@ -from __future__ import annotations - -from functools import cached_property - from pydantic import UUID4 -from mealie.schema.group import ShoppingListCreate, ShoppingListOut, ShoppingListSummary +from mealie.repos.repository_factory import AllRepositories +from mealie.schema.group import ShoppingListOut from mealie.schema.group.group_shopping_list import ShoppingListItemCreate -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_group_event -class ShoppingListService( - CrudHttpMixins[ShoppingListOut, ShoppingListCreate, ShoppingListCreate], - UserHttpService[int, ShoppingListOut], -): - event_func = create_group_event - _restrict_by_group = True - _schema = ShoppingListSummary - - @cached_property - def repo(self): - return self.db.group_shopping_lists +class ShoppingListService: + def __init__(self, repos: AllRepositories): + self.repos = repos + self.repo = repos.group_shopping_lists def add_recipe_ingredients_to_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut: - recipe = self.db.recipes.get_one(recipe_id, "id") + recipe = self.repos.recipes.get_one(recipe_id, "id") shopping_list = self.repo.get_one(list_id) to_create = [] diff --git a/mealie/services/group_services/webhook_service.py b/mealie/services/group_services/webhook_service.py deleted file mode 100644 index ef5fb5468f5d..000000000000 --- a/mealie/services/group_services/webhook_service.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from functools import cached_property - -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.crud_http_mixins import CrudHttpMixins -from mealie.services._base_http_service.http_services import UserHttpService -from mealie.services.events import create_group_event - -logger = get_logger(module=__name__) - - -class WebhookService(CrudHttpMixins[ReadWebhook, CreateWebhook, CreateWebhook], UserHttpService[int, ReadWebhook]): - event_func = create_group_event - _restrict_by_group = True - _schema = ReadWebhook - - @cached_property - def repo(self): - return self.db.webhooks - - def populate_item(self, id: int) -> ReadWebhook: - self.item = self.repo.get_one(id) - return self.item - - def get_all(self) -> list[ReadWebhook]: - return self.repo.get(self.group_id, match_key="group_id", limit=9999) - - def create_one(self, data: CreateWebhook) -> ReadWebhook: - data = self.cast(data, SaveWebhook) - return self._create_one(data) - - def update_one(self, data: CreateWebhook, item_id: int = None) -> ReadWebhook: - return self._update_one(data, item_id) - - def delete_one(self, id: int = None) -> ReadWebhook: - return self._delete_one(id) diff --git a/mealie/services/image/image.py b/mealie/services/image/image.py index 94ccd6c9174d..e1653aa5d28e 100644 --- a/mealie/services/image/image.py +++ b/mealie/services/image/image.py @@ -1,5 +1,4 @@ import shutil -from dataclasses import dataclass from pathlib import Path import requests @@ -8,18 +7,6 @@ from mealie.core import root_logger from mealie.schema.recipe import Recipe from mealie.services.image import minify -logger = root_logger.get_logger() - - -@dataclass -class ImageOptions: - ORIGINAL_IMAGE: str = "original.webp" - MINIFIED_IMAGE: str = "min-original.webp" - TINY_IMAGE: str = "tiny-original.webp" - - -IMG_OPTIONS = ImageOptions() - def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path: image_dir = Recipe(slug=recipe_slug).image_dir @@ -42,6 +29,7 @@ def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path: def scrape_image(image_url: str, slug: str) -> Path: + logger = root_logger.get_logger() logger.info(f"Image URL: {image_url}") _FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0" diff --git a/mealie/services/migrations/__init__.py b/mealie/services/migrations/__init__.py index 11d099ec8e86..7111958affd2 100644 --- a/mealie/services/migrations/__init__.py +++ b/mealie/services/migrations/__init__.py @@ -1,2 +1,4 @@ from .chowdown import * +from .mealie_alpha import * from .nextcloud import * +from .paprika import * diff --git a/mealie/services/parser_services/__init__.py b/mealie/services/parser_services/__init__.py index 0e346812c50d..b70424ab680a 100644 --- a/mealie/services/parser_services/__init__.py +++ b/mealie/services/parser_services/__init__.py @@ -1,2 +1 @@ from .ingredient_parser import * -from .ingredient_parser_service import * diff --git a/mealie/services/parser_services/crfpp/utils.py b/mealie/services/parser_services/crfpp/utils.py index 659739b56805..da46ad938e1e 100644 --- a/mealie/services/parser_services/crfpp/utils.py +++ b/mealie/services/parser_services/crfpp/utils.py @@ -8,38 +8,6 @@ def joinLine(columns): return "\t".join(columns) -def cleanUnicodeFractions(s): - """ - Replace unicode fractions with ascii representation, preceded by a - space. - - "1\x215e" => "1 7/8" - """ - - fractions = { - "\x215b": "1/8", - "\x215c": "3/8", - "\x215d": "5/8", - "\x215e": "7/8", - "\x2159": "1/6", - "\x215a": "5/6", - "\x2155": "1/5", - "\x2156": "2/5", - "\x2157": "3/5", - "\x2158": "4/5", - "\xbc": " 1/4", - "\xbe": "3/4", - "\x2153": "1/3", - "\x2154": "2/3", - "\xbd": "1/2", - } - - for f_unicode, f_ascii in fractions.items(): - s = s.replace(f_unicode, " " + f_ascii) - - return s - - def unclump(s): """ Replacess $'s with spaces. The reverse of clumpFractions. @@ -47,15 +15,6 @@ def unclump(s): return re.sub(r"\$", " ", s) -def normalizeToken(s): - """ - 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. - """ - return singularize(s) - - def getFeatures(token, index, tokens): """ Returns a list of features for a given token. diff --git a/mealie/services/parser_services/ingredient_parser.py b/mealie/services/parser_services/ingredient_parser.py index 98bbe5397826..bee87b3604fe 100644 --- a/mealie/services/parser_services/ingredient_parser.py +++ b/mealie/services/parser_services/ingredient_parser.py @@ -21,8 +21,9 @@ class ABCIngredientParser(ABC): Abstract class for ingredient parsers. """ + @abstractmethod def parse_one(self, ingredient_string: str) -> ParsedIngredient: - pass + ... @abstractmethod def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: @@ -97,6 +98,10 @@ class NLPParser(ABCIngredientParser): crf_models = crfpp.convert_list_to_crf_model(ingredients) return [self._crf_to_ingredient(crf_model) for crf_model in crf_models] + def parse_one(self, ingredient: str) -> ParsedIngredient: + items = self.parse_one([ingredient]) + return items[0] + __registrar = { RegisteredParser.nlp: NLPParser, diff --git a/mealie/services/parser_services/ingredient_parser_service.py b/mealie/services/parser_services/ingredient_parser_service.py deleted file mode 100644 index 4c2cd1d87590..000000000000 --- a/mealie/services/parser_services/ingredient_parser_service.py +++ /dev/null @@ -1,33 +0,0 @@ -from mealie.schema.recipe import RecipeIngredient -from mealie.services._base_http_service.http_services import UserHttpService - -from .ingredient_parser import ABCIngredientParser, RegisteredParser, get_parser - - -class IngredientParserService(UserHttpService): - parser: ABCIngredientParser - - def __init__(self, parser: RegisteredParser = RegisteredParser.nlp, *args, **kwargs) -> None: - self.set_parser(parser) - super().__init__(*args, **kwargs) - - def set_parser(self, parser: RegisteredParser) -> None: - self.parser = get_parser(parser) - - def populate_item(self) -> None: - """Satisfy abstract method""" - pass - - def parse_ingredient(self, ingredient: str) -> RecipeIngredient: - parsed = self.parser.parse([ingredient]) - - if parsed: - return parsed[0] - # TODO: Raise Exception - - def parse_ingredients(self, ingredients: list[str]) -> list[RecipeIngredient]: - parsed = self.parser.parse(ingredients) - - if parsed: - return parsed - # TODO: Raise Exception diff --git a/mealie/services/recipe/mixins.py b/mealie/services/recipe/mixins.py deleted file mode 100644 index 945d44018faa..000000000000 --- a/mealie/services/recipe/mixins.py +++ /dev/null @@ -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. - - - -""" - -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) diff --git a/mealie/services/recipe/recipe_bulk_service.py b/mealie/services/recipe/recipe_bulk_service.py index e3b4cf359935..415ed99d6487 100644 --- a/mealie/services/recipe/recipe_bulk_service.py +++ b/mealie/services/recipe/recipe_bulk_service.py @@ -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) diff --git a/mealie/services/recipe/recipe_comments_service.py b/mealie/services/recipe/recipe_comments_service.py deleted file mode 100644 index 43cf378f8cef..000000000000 --- a/mealie/services/recipe/recipe_comments_service.py +++ /dev/null @@ -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) diff --git a/mealie/services/recipe/recipe_food_service.py b/mealie/services/recipe/recipe_food_service.py deleted file mode 100644 index 16e99cf4b219..000000000000 --- a/mealie/services/recipe/recipe_food_service.py +++ /dev/null @@ -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) diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index b80606b297fb..43e875987141 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -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. + + + +""" + +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) diff --git a/mealie/services/recipe/recipe_tool_service.py b/mealie/services/recipe/recipe_tool_service.py deleted file mode 100644 index 3c2d5ad15444..000000000000 --- a/mealie/services/recipe/recipe_tool_service.py +++ /dev/null @@ -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) diff --git a/mealie/services/recipe/recipe_unit_service.py b/mealie/services/recipe/recipe_unit_service.py deleted file mode 100644 index b6c44d0e59ba..000000000000 --- a/mealie/services/recipe/recipe_unit_service.py +++ /dev/null @@ -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) diff --git a/mealie/services/recipe/template_service.py b/mealie/services/recipe/template_service.py index 849bd8345563..8fa1000f2224 100644 --- a/mealie/services/recipe/template_service.py +++ b/mealie/services/recipe/template_service.py @@ -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.") diff --git a/mealie/services/scheduler/scheduled_func.py b/mealie/services/scheduler/scheduled_func.py index 54804986ceb9..0e2f0115e2d8 100644 --- a/mealie/services/scheduler/scheduled_func.py +++ b/mealie/services/scheduler/scheduled_func.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from dataclasses import dataclass from typing import Callable, Tuple @@ -12,7 +10,7 @@ class Cron: minutes: int @classmethod - def parse(cls, time_str: str) -> Cron: + def parse(cls, time_str: str) -> "Cron": time = time_str.split(":") return Cron(hours=int(time[0]), minutes=int(time[1])) diff --git a/mealie/services/scheduler/scheduler_registry.py b/mealie/services/scheduler/scheduler_registry.py index 23ece5092f4d..a0ca65ee0dfe 100644 --- a/mealie/services/scheduler/scheduler_registry.py +++ b/mealie/services/scheduler/scheduler_registry.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Callable, Iterable from mealie.core import root_logger diff --git a/mealie/services/scheduler/scheduler_service.py b/mealie/services/scheduler/scheduler_service.py index 98d689f4a5fa..29b3c0e85892 100644 --- a/mealie/services/scheduler/scheduler_service.py +++ b/mealie/services/scheduler/scheduler_service.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore diff --git a/mealie/services/scraper/cleaner.py b/mealie/services/scraper/cleaner.py index 6938454c7ecd..8ab8e93f6d53 100644 --- a/mealie/services/scraper/cleaner.py +++ b/mealie/services/scraper/cleaner.py @@ -72,11 +72,6 @@ def category(category: str): return [] -def clean_html(raw_html): - cleanr = re.compile("<.*?>") - return re.sub(cleanr, "", raw_html) - - def clean_nutrition(nutrition: Optional[dict]) -> dict[str, str]: # Assumes that all units are supplied in grams, except sodium which may be in mg. diff --git a/mealie/services/scraper/recipe_scraper.py b/mealie/services/scraper/recipe_scraper.py index 88b993eeba11..e55f9e355a75 100644 --- a/mealie/services/scraper/recipe_scraper.py +++ b/mealie/services/scraper/recipe_scraper.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Type from mealie.schema.recipe.recipe import Recipe diff --git a/mealie/services/scraper/scraper.py b/mealie/services/scraper/scraper.py index b741aab6ce81..a99f60ca1070 100644 --- a/mealie/services/scraper/scraper.py +++ b/mealie/services/scraper/scraper.py @@ -1,10 +1,7 @@ -from __future__ import annotations - from enum import Enum from uuid import uuid4 from fastapi import HTTPException, status -from recipe_scrapers import NoSchemaFoundInWildMode, WebsiteNotImplementedError, scrape_me from slugify import slugify from mealie.core.root_logger import get_logger @@ -13,7 +10,11 @@ from mealie.services.image.image import scrape_image from .recipe_scraper import RecipeScraper -logger = get_logger() + +class ParserErrors(str, Enum): + BAD_RECIPE_DATA = "BAD_RECIPE_DATA" + NO_RECIPE_DATA = "NO_RECIPE_DATA" + CONNECTION_ERROR = "CONNECTION_ERROR" def create_from_url(url: str) -> Recipe: @@ -32,6 +33,7 @@ def create_from_url(url: str) -> Recipe: if not new_recipe: raise HTTPException(status.HTTP_400_BAD_REQUEST, {"details": ParserErrors.BAD_RECIPE_DATA.value}) + logger = get_logger() logger.info(f"Image {new_recipe.image}") new_recipe.image = download_image_for_recipe(new_recipe.slug, new_recipe.image) @@ -42,60 +44,13 @@ def create_from_url(url: str) -> Recipe: return new_recipe -class ParserErrors(str, Enum): - BAD_RECIPE_DATA = "BAD_RECIPE_DATA" - NO_RECIPE_DATA = "NO_RECIPE_DATA" - CONNECTION_ERROR = "CONNECTION_ERROR" - - -def scrape_from_url(url: str): - """Entry function to scrape a recipe from a url - This will determine if a url can be parsed and return None if not, to allow another parser to try. - This keyword is used on the frontend to reference a localized string to present on the UI. - - Args: - url (str): String Representing the URL - - Raises: - HTTPException: 400_BAD_REQUEST - See ParserErrors Class for Key Details - - Returns: - Optional[Scraped schema for cleaning] - """ - try: - scraped_schema = scrape_me(url) - except (WebsiteNotImplementedError, AttributeError): - try: - scraped_schema = scrape_me(url, wild_mode=True) - except (NoSchemaFoundInWildMode, AttributeError): - logger.error("Recipe Scraper was unable to extract a recipe.") - return None - - except ConnectionError: - raise HTTPException(status.HTTP_400_BAD_REQUEST, {"details": ParserErrors.CONNECTION_ERROR.value}) - - # Check to see if the recipe is valid - try: - ingredients = scraped_schema.ingredients() - instruct = scraped_schema.instructions() - except Exception: - ingredients = [] - instruct = [] - - if instruct and ingredients: - return scraped_schema - - # recipe_scrapers did not get a valid recipe. - # Return None to let another scraper try. - return None - - def download_image_for_recipe(slug, image_url) -> str | None: img_name = None try: img_path = scrape_image(image_url, slug) img_name = img_path.name except Exception as e: + logger = get_logger() logger.error(f"Error Scraping Image: {e}") img_name = None diff --git a/mealie/services/scraper/scraper_strategies.py b/mealie/services/scraper/scraper_strategies.py index 56b96dbdb60e..3c609910fcc6 100644 --- a/mealie/services/scraper/scraper_strategies.py +++ b/mealie/services/scraper/scraper_strategies.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from abc import ABC, abstractmethod from typing import Any, Callable, Tuple @@ -41,10 +39,6 @@ class ABCScraperStrategy(ABC): class RecipeScraperPackage(ABCScraperStrategy): - """ - Abstract class for all recipe parsers. - """ - def clean_scraper(self, scraped_data: SchemaScraperFactory.SchemaScraper, url: str) -> Recipe: def try_get_default(func_call: Callable, get_attr: str, default: Any, clean_func=None): value = default diff --git a/mealie/services/server_tasks/__init__.py b/mealie/services/server_tasks/__init__.py index cc4d30bf7cc9..24eb41431ba3 100644 --- a/mealie/services/server_tasks/__init__.py +++ b/mealie/services/server_tasks/__init__.py @@ -1,2 +1 @@ from .background_executory import * -from .tasks_http_service import * diff --git a/mealie/services/server_tasks/background_executory.py b/mealie/services/server_tasks/background_executory.py index feb0dc7aa2b1..51e10763d56b 100644 --- a/mealie/services/server_tasks/background_executory.py +++ b/mealie/services/server_tasks/background_executory.py @@ -2,15 +2,23 @@ from random import getrandbits from time import sleep from typing import Any, Callable +from fastapi import BackgroundTasks +from pydantic import UUID4 from sqlalchemy.orm import Session from mealie.repos.all_repositories import get_repositories +from mealie.repos.repository_factory import AllRepositories from mealie.schema.server.tasks import ServerTask, ServerTaskCreate, ServerTaskNames -from .._base_http_service.http_services import UserHttpService +class BackgroundExecutor: + sleep_time = 60 + + def __init__(self, group_id: UUID4, repos: AllRepositories, bg: BackgroundTasks) -> None: + self.group_id = group_id + self.repos = repos + self.background_tasks = bg -class BackgroundExecutor(UserHttpService): def populate_item(self, _: int) -> ServerTask: pass @@ -24,9 +32,9 @@ class BackgroundExecutor(UserHttpService): """ server_task = ServerTaskCreate(group_id=self.group_id, name=task_name) - server_task = self.db.server_tasks.create(server_task) + server_task = self.repos.server_tasks.create(server_task) - self.background_tasks.add_task(func, *args, **kwargs, task_id=server_task.id, session=self.session) + self.background_tasks.add_task(func, *args, **kwargs, task_id=server_task.id, session=self.repos.session) return server_task @@ -38,7 +46,7 @@ def test_executor_func(task_id: int, session: Session) -> None: task.append_log("test task has started") task.append_log("test task sleeping for 60 seconds") - sleep(60) + sleep(BackgroundExecutor.sleep_time) task.append_log("test task has finished sleep") diff --git a/mealie/services/server_tasks/tasks_http_service.py b/mealie/services/server_tasks/tasks_http_service.py deleted file mode 100644 index 9d06f3c269b7..000000000000 --- a/mealie/services/server_tasks/tasks_http_service.py +++ /dev/null @@ -1,36 +0,0 @@ -from functools import cached_property - -from mealie.schema.server import ServerTask -from mealie.services._base_http_service.http_services import AdminHttpService, UserHttpService - - -class ServerTasksHttpService(UserHttpService[int, ServerTask]): - _restrict_by_group = True - _schema = ServerTask - - @cached_property - def repo(self): - return self.db.server_tasks - - def populate_item(self, id: int) -> ServerTask: - self.item = self.repo.get_one(id) - return self.item - - def get_all(self) -> list[ServerTask]: - return self.repo.multi_query(query_by={"group_id": self.group_id}, order_by="created_at") - - -class AdminServerTasks(AdminHttpService[int, ServerTask]): - _restrict_by_group = True - _schema = ServerTask - - @cached_property - def repo(self): - return self.db.server_tasks - - def populate_item(self, id: int) -> ServerTask: - self.item = self.repo.get_one(id) - return self.item - - def get_all(self) -> list[ServerTask]: - return self.repo.get_all(order_by="created_at") diff --git a/mealie/services/shared/__init__.py b/mealie/services/shared/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/mealie/services/shared/recipe_shared_service.py b/mealie/services/shared/recipe_shared_service.py deleted file mode 100644 index 51ec00d25957..000000000000 --- a/mealie/services/shared/recipe_shared_service.py +++ /dev/null @@ -1,51 +0,0 @@ -from functools import cached_property - -from pydantic import UUID4 - -from mealie.schema.recipe.recipe_share_token import ( - RecipeShareToken, - RecipeShareTokenCreate, - RecipeShareTokenSave, - RecipeShareTokenSummary, -) -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 SharedRecipeService( - CrudHttpMixins[RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenCreate], - UserHttpService[UUID4, RecipeShareToken], -): - event_func = create_recipe_event - _restrict_by_group = False - _schema = RecipeShareToken - - @cached_property - def repo(self): - return self.db.recipe_share_tokens - - def populate_item(self, id: UUID4) -> RecipeShareToken: - self.item = self.repo.get_one(id) - return self.item - - def get_all(self, recipe_id=None) -> list[RecipeShareTokenSummary]: - # sourcery skip: assign-if-exp, inline-immediately-returned-variable - if recipe_id: - return self.db.recipe_share_tokens.multi_query( - {"group_id": self.group_id, "recipe_id": recipe_id}, - override_schema=RecipeShareTokenSummary, - ) - else: - return self.db.recipe_share_tokens.multi_query( - {"group_id": self.group_id}, override_schema=RecipeShareTokenSummary - ) - - def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken: - save_data = RecipeShareTokenSave(**data.dict(), group_id=self.group_id) - return self._create_one(save_data) - - def delete_one(self, item_id: UUID4 = None) -> None: - item_id = item_id or self.item.id - - return self.repo.delete(item_id) diff --git a/mealie/services/user_services/__init__.py b/mealie/services/user_services/__init__.py index dda534e9f052..e69de29bb2d1 100644 --- a/mealie/services/user_services/__init__.py +++ b/mealie/services/user_services/__init__.py @@ -1 +0,0 @@ -from .user_service import * diff --git a/mealie/services/user_services/registration_service.py b/mealie/services/user_services/registration_service.py index bfcb72961625..1c4fd634d009 100644 --- a/mealie/services/user_services/registration_service.py +++ b/mealie/services/user_services/registration_service.py @@ -1,56 +1,23 @@ +from logging import Logger from uuid import uuid4 from fastapi import HTTPException, status -from mealie.core.root_logger import get_logger from mealie.core.security import hash_password +from mealie.repos.repository_factory import AllRepositories from mealie.schema.group.group_preferences import CreateGroupPreferences from mealie.schema.user.registration import CreateUserRegistration from mealie.schema.user.user import GroupBase, GroupInDB, PrivateUser, UserIn -from mealie.services._base_http_service.http_services import PublicHttpService -from mealie.services.events import create_user_event from mealie.services.group_services.group_utils import create_new_group -logger = get_logger(module=__name__) +class RegistrationService: + logger: Logger + repos: AllRepositories -class RegistrationService(PublicHttpService[int, str]): - event_func = create_user_event - - def populate_item() -> None: - pass - - def register_user(self, registration: CreateUserRegistration) -> PrivateUser: - self.registration = registration - - logger.info(f"Registering user {registration.username}") - token_entry = None - new_group = False - - if registration.group: - new_group = True - group = self._register_new_group() - - elif registration.group_token and registration.group_token != "": - token_entry = self.db.group_invite_tokens.get_one(registration.group_token) - if not token_entry: - raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Invalid group token"}) - group = self.db.groups.get(token_entry.group_id) - else: - raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Missing group"}) - - user = self._create_new_user(group, new_group) - - if token_entry and user: - token_entry.uses_left = token_entry.uses_left - 1 - - if token_entry.uses_left == 0: - self.db.group_invite_tokens.delete(token_entry.token) - - else: - self.db.group_invite_tokens.update(token_entry.token, token_entry) - - return user + def __init__(self, logger: Logger, db: AllRepositories): + self.logger = logger + self.repos = db def _create_new_user(self, group: GroupInDB, new_group=bool) -> PrivateUser: new_user = UserIn( @@ -65,7 +32,7 @@ class RegistrationService(PublicHttpService[int, str]): can_organize=new_group, ) - return self.db.users.create(new_user) + return self.repos.users.create(new_user) def _register_new_group(self) -> GroupInDB: group_data = GroupBase(name=self.registration.group) @@ -82,4 +49,36 @@ class RegistrationService(PublicHttpService[int, str]): recipe_disable_amount=self.registration.advanced, ) - return create_new_group(self.db, group_data, group_preferences) + return create_new_group(self.repos, group_data, group_preferences) + + def register_user(self, registration: CreateUserRegistration) -> PrivateUser: + self.registration = registration + + self.logger.info(f"Registering user {registration.username}") + token_entry = None + new_group = False + + if registration.group: + new_group = True + group = self._register_new_group() + + elif registration.group_token and registration.group_token != "": + token_entry = self.repos.group_invite_tokens.get_one(registration.group_token) + if not token_entry: + raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Invalid group token"}) + group = self.repos.groups.get(token_entry.group_id) + else: + raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Missing group"}) + + user = self._create_new_user(group, new_group) + + if token_entry and user: + token_entry.uses_left = token_entry.uses_left - 1 + + if token_entry.uses_left == 0: + self.repos.group_invite_tokens.delete(token_entry.token) + + else: + self.repos.group_invite_tokens.update(token_entry.token, token_entry) + + return user diff --git a/mealie/services/user_services/user_service.py b/mealie/services/user_services/user_service.py deleted file mode 100644 index 7deefa550d68..000000000000 --- a/mealie/services/user_services/user_service.py +++ /dev/null @@ -1,50 +0,0 @@ -from pathlib import Path - -from fastapi import HTTPException, status - -from mealie.core.root_logger import get_logger -from mealie.core.security import hash_password, verify_password -from mealie.schema.user.user import ChangePassword, PrivateUser -from mealie.services._base_http_service.http_services import UserHttpService -from mealie.services.events import create_user_event - -logger = get_logger(module=__name__) - - -class UserService(UserHttpService[int, str]): - event_func = create_user_event - acting_user: PrivateUser = None - - def populate_item(self, item_id: int) -> None: - self.acting_user = self.db.users.get_one(item_id) - return self.acting_user - - def assert_existing(self, id) -> PrivateUser: - self.populate_item(id) - 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: - # only admins can edit other users - raise HTTPException(status.HTTP_403_FORBIDDEN, detail="NOT_AN_ADMIN") - - def _populate_target_user(self, id: int = None): - if id: - self.target_user = self.db.users.get(id) - if not self.target_user: - raise HTTPException(status.HTTP_404_NOT_FOUND) - else: - self.target_user = self.acting_user - - def change_password(self, password_change: ChangePassword) -> PrivateUser: - if not verify_password(password_change.current_password, self.target_user.password): - raise HTTPException(status.HTTP_400_BAD_REQUEST) - - self.target_user.password = hash_password(password_change.new_password) - - return self.db.users.update_password(self.target_user.id, self.target_user.password) - - def set_profile_picture(self, file_path: Path) -> PrivateUser: - pass diff --git a/mealie/utils/error_messages.py b/mealie/utils/error_messages.py deleted file mode 100644 index c9904b9d22da..000000000000 --- a/mealie/utils/error_messages.py +++ /dev/null @@ -1,81 +0,0 @@ -from dataclasses import dataclass - - -@dataclass -class ErrorMessages: - """ - This enum class holds the text values that represent the errors returned when - something goes wrong on the server side. - - Example: {"details": "general-failure"} - - The items contained within the '#' are automatically generated by a script in the scripts directory. - DO NOT EDIT THE CONTENTS BETWEEN THOSE. If you need to add a custom error message, do so in the lines - above. - - Why Generate This!?!?! If we generate static errors on the backend we can ensure that a constant - set or error messages will be returned to the frontend. As such we can use the "details" key to - look up localized messages in the frontend. as such DO NOT change the generated or manual codes - without making the necessary changes on the client side code. - """ - - general_failure = "general-failure" - - # CODE_GEN_ID: ERROR_MESSAGE_ENUMS - backup_create_failure = "backup-create-failure" - backup_update_failure = "backup-update-failure" - backup_delete_failure = "backup-delete-failure" - - cookbook_create_failure = "cookbook-create-failure" - cookbook_update_failure = "cookbook-update-failure" - cookbook_delete_failure = "cookbook-delete-failure" - - event_create_failure = "event-create-failure" - event_update_failure = "event-update-failure" - event_delete_failure = "event-delete-failure" - - food_create_failure = "food-create-failure" - food_update_failure = "food-update-failure" - food_delete_failure = "food-delete-failure" - - group_create_failure = "group-create-failure" - group_update_failure = "group-update-failure" - group_delete_failure = "group-delete-failure" - - ingredient_create_failure = "ingredient-create-failure" - ingredient_update_failure = "ingredient-update-failure" - ingredient_delete_failure = "ingredient-delete-failure" - - mealplan_create_failure = "mealplan-create-failure" - mealplan_update_failure = "mealplan-update-failure" - mealplan_delete_failure = "mealplan-delete-failure" - - migration_create_failure = "migration-create-failure" - migration_update_failure = "migration-update-failure" - migration_delete_failure = "migration-delete-failure" - - recipe_create_failure = "recipe-create-failure" - recipe_update_failure = "recipe-update-failure" - recipe_delete_failure = "recipe-delete-failure" - - scraper_create_failure = "scraper-create-failure" - scraper_update_failure = "scraper-update-failure" - scraper_delete_failure = "scraper-delete-failure" - - token_create_failure = "token-create-failure" - token_update_failure = "token-update-failure" - token_delete_failure = "token-delete-failure" - - unit_create_failure = "unit-create-failure" - unit_update_failure = "unit-update-failure" - unit_delete_failure = "unit-delete-failure" - - user_create_failure = "user-create-failure" - user_update_failure = "user-update-failure" - user_delete_failure = "user-delete-failure" - - webhook_create_failure = "webhook-create-failure" - webhook_update_failure = "webhook-update-failure" - webhook_delete_failure = "webhook-delete-failure" - - # END ERROR_MESSAGE_ENUMS diff --git a/mealie/utils/lifespan_tracker.py b/mealie/utils/lifespan_tracker.py new file mode 100644 index 000000000000..86f76d1ef84b --- /dev/null +++ b/mealie/utils/lifespan_tracker.py @@ -0,0 +1,26 @@ +import time + + +# log_lifetime is a class decorator that logs the creation and destruction of a class +# It is used to track the lifespan of a class during development or testing. +# It SHOULD NOT be used in production code. +def log_lifetime(cls): + class LifeTimeClass(cls): + def __init__(self, *args, **kwargs): + print(f"Creating an instance of {cls.__name__}") # noqa: T001 + self.__lifespan_timer_start = time.perf_counter() + + super().__init__(*args, **kwargs) + + def __del__(self): + toc = time.perf_counter() + print(f"Downloaded the tutorial in {toc - self.__lifespan_timer_start:0.4f} seconds") # noqa: T001 + + print(f"Deleting an instance of {cls.__name__}") # noqa: T001 + + try: + super().__del__() + except AttributeError: + pass + + return LifeTimeClass diff --git a/mealie/utils/post_webhooks.py b/mealie/utils/post_webhooks.py index d421608df440..8bda34be0fcb 100644 --- a/mealie/utils/post_webhooks.py +++ b/mealie/utils/post_webhooks.py @@ -1,31 +1,7 @@ -import json +from asyncio.log import logger -import requests from sqlalchemy.orm.session import Session -from mealie.db.db_setup import create_session -from mealie.repos.all_repositories import get_repositories -from mealie.schema.user import GroupInDB -from mealie.services.events import create_scheduled_event - def post_webhooks(group: int, session: Session = None, force=True): - session = session or create_session() - db = get_repositories(session) - group_settings: GroupInDB = db.groups.get(group) - - if not group_settings.webhook_enable and not force: - return - - # TODO: Fix Mealplan Webhooks - todays_recipe = None - - if not todays_recipe: - return - - for url in group_settings.webhook_urls: - requests.post(url, json=json.loads(todays_recipe.json(by_alias=True))) - - create_scheduled_event("Meal Plan Webhook", f"Meal plan webhook executed for group '{group}'") - - session.close() + logger.error("post_webhooks is depreciated") diff --git a/poetry.lock b/poetry.lock index 389d3fba1c50..4760106cfc60 100644 --- a/poetry.lock +++ b/poetry.lock @@ -256,6 +256,18 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] toml = ["toml"] +[[package]] +name = "coveragepy-lcov" +version = "0.1.1" +description = "A simple .coverage to LCOV converter" +category = "dev" +optional = false +python-versions = ">=3.8,<4.0" + +[package.dependencies] +click = ">=7.1.2,<8.0.0" +coverage = ">=5.5,<6.0" + [[package]] name = "cssselect" version = "1.1.0" @@ -1446,7 +1458,7 @@ pgsql = ["psycopg2-binary"] [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "e39f0333c3869cda1b5aed5a1399dbde99bce2a0b956abb0899b79d3590d0eb9" +content-hash = "869d87ae4fb7dff15f0b53dddde737ce8a6c2b13217d6690f4e2b90d3198a18c" [metadata.files] aiofiles = [ @@ -1636,6 +1648,10 @@ coverage = [ {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] +coveragepy-lcov = [ + {file = "coveragepy-lcov-0.1.1.tar.gz", hash = "sha256:68a7e376489b053123d32c7890060e8621a89c33d10a78bbdf647b1bcd4d528a"}, + {file = "coveragepy_lcov-0.1.1-py3-none-any.whl", hash = "sha256:dc258650d8f98117273eb9b6e5616240296764ea44b13c9e75a310d3e7997095"}, +] cssselect = [ {file = "cssselect-1.1.0-py2.py3-none-any.whl", hash = "sha256:f612ee47b749c877ebae5bb77035d8f4202c6ad0f0fc1271b3c18ad6c4468ecf"}, {file = "cssselect-1.1.0.tar.gz", hash = "sha256:f95f8dedd925fd8f54edb3d2dfb44c190d9d18512377d3c1e2388d16126879bc"}, diff --git a/pyproject.toml b/pyproject.toml index 1dc1b75f90d1..55467b22e6c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ rich = "^10.7.0" isort = "^5.9.3" flake8-print = "^4.0.0" black = "^21.12b0" +coveragepy-lcov = "^0.1.1" [build-system] requires = ["poetry-core>=1.0.0"] @@ -60,6 +61,15 @@ build-backend = "poetry.core.masonry.api" [tool.black] line-length = 120 +target-version = ["py310"] + +[tool.vulture] +exclude = ["**/models/**/*.py", "dir/"] +ignore_decorators = ["@*router.*", "@app.on_event", "@validator", "@controller"] +make_whitelist = true +min_confidence = 60 +paths = ["mealie"] +sort_by_size = true [tool.isort] profile = "black" diff --git a/tests/integration_tests/admin_tests/test_admin_about.py b/tests/integration_tests/admin_tests/test_admin_about.py new file mode 100644 index 000000000000..5867ece6ef8e --- /dev/null +++ b/tests/integration_tests/admin_tests/test_admin_about.py @@ -0,0 +1,52 @@ +from fastapi.testclient import TestClient + +from mealie.core.config import get_app_settings +from mealie.core.settings.static import APP_VERSION +from tests.utils.fixture_schemas import TestUser + + +class Routes: + base = "/api/admin/about" + statistics = f"{base}/statistics" + check = f"{base}/check" + + +def test_admin_about_get_app_info(api_client: TestClient, admin_user: TestUser): + response = api_client.get(Routes.base, headers=admin_user.token) + + as_dict = response.json() + + settings = get_app_settings() + + assert as_dict["version"] == APP_VERSION + assert as_dict["demoStatus"] == settings.IS_DEMO + assert as_dict["apiPort"] == settings.API_PORT + assert as_dict["apiDocs"] == settings.API_DOCS + assert as_dict["dbType"] == settings.DB_ENGINE + # assert as_dict["dbUrl"] == settings.DB_URL_PUBLIC + assert as_dict["defaultGroup"] == settings.DEFAULT_GROUP + + +def test_admin_about_get_app_statistics(api_client: TestClient, admin_user: TestUser): + response = api_client.get(Routes.statistics, headers=admin_user.token) + + as_dict = response.json() + + # Smoke Test - Test the endpoint returns something thats a number + assert as_dict["totalRecipes"] >= 0 + assert as_dict["uncategorizedRecipes"] >= 0 + assert as_dict["untaggedRecipes"] >= 0 + assert as_dict["totalUsers"] >= 0 + assert as_dict["totalGroups"] >= 0 + + +def test_admin_about_check_app_config(api_client: TestClient, admin_user: TestUser): + response = api_client.get(Routes.check, headers=admin_user.token) + + as_dict = response.json() + + # Smoke Test - Test the endpoint returns something thats a the expected shape + assert as_dict["emailReady"] in [True, False] + assert as_dict["ldapReady"] in [True, False] + assert as_dict["baseUrlSet"] in [True, False] + assert as_dict["isUpToDate"] in [True, False] diff --git a/tests/integration_tests/admin_tests/test_admin_background_tasks.py b/tests/integration_tests/admin_tests/test_admin_background_tasks.py new file mode 100644 index 000000000000..ae0e813b1a0e --- /dev/null +++ b/tests/integration_tests/admin_tests/test_admin_background_tasks.py @@ -0,0 +1,24 @@ +from fastapi.testclient import TestClient + +from mealie.services.server_tasks.background_executory import BackgroundExecutor +from tests.utils.fixture_schemas import TestUser + + +class Routes: + base = "/api/admin/server-tasks" + + +def test_admin_server_tasks_test_and_get(api_client: TestClient, admin_user: TestUser): + # Bootstrap Timer + BackgroundExecutor.sleep_time = 0.1 + + response = api_client.post(Routes.base, headers=admin_user.token) + assert response.status_code == 201 + + response = api_client.get(Routes.base, headers=admin_user.token) + as_dict = response.json() + + assert len(as_dict) == 1 + + # Reset Timer + BackgroundExecutor.sleep_time = 60 diff --git a/tests/integration_tests/admin_tests/test_group_admin_actions.py b/tests/integration_tests/admin_tests/test_admin_group_actions.py similarity index 100% rename from tests/integration_tests/admin_tests/test_group_admin_actions.py rename to tests/integration_tests/admin_tests/test_admin_group_actions.py diff --git a/tests/integration_tests/category_tag_tool_tests/test_category.py b/tests/integration_tests/category_tag_tool_tests/test_category.py new file mode 100644 index 000000000000..24890667e361 --- /dev/null +++ b/tests/integration_tests/category_tag_tool_tests/test_category.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass + +import pytest +from fastapi.testclient import TestClient + +from mealie.schema.static import recipe_keys +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +class Routes: + base = "/api/categories" + recipes = "/api/recipes" + + def item(item_id: int) -> str: + return f"{Routes.base}/{item_id}" + + def recipe(recipe_id: int) -> str: + return f"{Routes.recipes}/{recipe_id}" + + +@dataclass +class TestRecipeCategory: + id: int + name: str + slug: str + recipes: list + + +@pytest.fixture(scope="function") +def category(api_client: TestClient, unique_user: TestUser) -> TestRecipeCategory: + data = {"name": random_string(10)} + + response = api_client.post(Routes.base, json=data, headers=unique_user.token) + + assert response.status_code == 201 + + as_json = response.json() + + yield TestRecipeCategory( + id=as_json["id"], + name=data["name"], + slug=as_json["slug"], + recipes=[], + ) + + try: + response = api_client.delete(Routes.item(response.json()["slug"]), headers=unique_user.token) + except Exception: + pass + + +def test_create_category(api_client: TestClient, unique_user: TestUser): + data = {"name": random_string(10)} + response = api_client.post(Routes.base, json=data, headers=unique_user.token) + assert response.status_code == 201 + + +def test_read_category(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser): + response = api_client.get(Routes.item(category.slug), headers=unique_user.token) + assert response.status_code == 200 + + as_json = response.json() + assert as_json["id"] == category.id + assert as_json["name"] == category.name + + +def test_update_category(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser): + update_data = { + "id": category.id, + "name": random_string(10), + "slug": category.slug, + } + + response = api_client.put(Routes.item(category.slug), json=update_data, headers=unique_user.token) + assert response.status_code == 200 + + as_json = response.json() + assert as_json["id"] == category.id + assert as_json["name"] == update_data["name"] + + +def test_delete_category(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser): + response = api_client.delete(Routes.item(category.slug), headers=unique_user.token) + assert response.status_code == 200 + + +def test_recipe_category_association(api_client: TestClient, category: TestRecipeCategory, unique_user: TestUser): + # Setup Recipe + recipe_data = {"name": random_string(10)} + response = api_client.post(Routes.recipes, json=recipe_data, headers=unique_user.token) + slug = response.json() + assert response.status_code == 201 + + # Get Recipe Data + response = api_client.get(Routes.recipe(slug), headers=unique_user.token) + as_json = response.json() + as_json[recipe_keys.recipe_category] = [{"id": category.id, "name": category.name, "slug": category.slug}] + + # Update Recipe + response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token) + assert response.status_code == 200 + + # Get Recipe Data + response = api_client.get(Routes.recipe(slug), headers=unique_user.token) + as_json = response.json() + assert as_json[recipe_keys.recipe_category][0]["slug"] == category.slug diff --git a/tests/integration_tests/category_tag_tool_tests/test_tags.py b/tests/integration_tests/category_tag_tool_tests/test_tags.py new file mode 100644 index 000000000000..6aa46d9cbe6a --- /dev/null +++ b/tests/integration_tests/category_tag_tool_tests/test_tags.py @@ -0,0 +1,106 @@ +from dataclasses import dataclass + +import pytest +from fastapi.testclient import TestClient + +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +class Routes: + base = "/api/tags" + recipes = "/api/recipes" + + def item(item_id: int) -> str: + return f"{Routes.base}/{item_id}" + + def recipe(recipe_id: int) -> str: + return f"{Routes.recipes}/{recipe_id}" + + +@dataclass +class TestRecipeTag: + id: int + name: str + slug: str + recipes: list + + +@pytest.fixture(scope="function") +def tag(api_client: TestClient, unique_user: TestUser) -> TestRecipeTag: + data = {"name": random_string(10)} + + response = api_client.post(Routes.base, json=data, headers=unique_user.token) + + assert response.status_code == 201 + + as_json = response.json() + + yield TestRecipeTag( + id=as_json["id"], + name=data["name"], + slug=as_json["slug"], + recipes=[], + ) + + try: + response = api_client.delete(Routes.item(response.json()["slug"]), headers=unique_user.token) + except Exception: + pass + + +def test_create_tag(api_client: TestClient, unique_user: TestUser): + data = {"name": random_string(10)} + response = api_client.post(Routes.base, json=data, headers=unique_user.token) + assert response.status_code == 201 + + +def test_read_tag(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser): + response = api_client.get(Routes.item(tag.slug), headers=unique_user.token) + assert response.status_code == 200 + + as_json = response.json() + assert as_json["id"] == tag.id + assert as_json["name"] == tag.name + + +def test_update_tag(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser): + update_data = { + "id": tag.id, + "name": random_string(10), + "slug": tag.slug, + } + + response = api_client.put(Routes.item(tag.slug), json=update_data, headers=unique_user.token) + assert response.status_code == 200 + + as_json = response.json() + assert as_json["id"] == tag.id + assert as_json["name"] == update_data["name"] + + +def test_delete_tag(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser): + response = api_client.delete(Routes.item(tag.slug), headers=unique_user.token) + assert response.status_code == 200 + + +def test_recipe_tag_association(api_client: TestClient, tag: TestRecipeTag, unique_user: TestUser): + # Setup Recipe + recipe_data = {"name": random_string(10)} + response = api_client.post(Routes.recipes, json=recipe_data, headers=unique_user.token) + slug = response.json() + assert response.status_code == 201 + + # Get Recipe Data + response = api_client.get(Routes.recipe(slug), headers=unique_user.token) + as_json = response.json() + as_json["tags"] = [{"id": tag.id, "name": tag.name, "slug": tag.slug}] + + # Update Recipe + response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token) + assert response.status_code == 200 + + # Get Recipe Data + response = api_client.get(Routes.recipe(slug), headers=unique_user.token) + as_json = response.json() + assert as_json["tags"][0]["slug"] == tag.slug diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_tools.py b/tests/integration_tests/category_tag_tool_tests/test_tools.py similarity index 99% rename from tests/integration_tests/user_recipe_tests/test_recipe_tools.py rename to tests/integration_tests/category_tag_tool_tests/test_tools.py index 338827a10c14..77636de03271 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_tools.py +++ b/tests/integration_tests/category_tag_tool_tests/test_tools.py @@ -63,7 +63,6 @@ def test_read_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: Te assert response.status_code == 200 as_json = response.json() - assert as_json["id"] == tool.id assert as_json["name"] == tool.name @@ -80,7 +79,6 @@ def test_update_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: assert response.status_code == 200 as_json = response.json() - assert as_json["id"] == tool.id assert as_json["name"] == update_data["name"] @@ -93,28 +91,20 @@ def test_delete_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: def test_recipe_tool_association(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser): # Setup Recipe recipe_data = {"name": random_string(10)} - response = api_client.post(Routes.recipes, json=recipe_data, headers=unique_user.token) - slug = response.json() - assert response.status_code == 201 # Get Recipe Data response = api_client.get(Routes.recipe(slug), headers=unique_user.token) - as_json = response.json() - as_json["tools"] = [{"id": tool.id, "name": tool.name, "slug": tool.slug}] # Update Recipe response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token) - assert response.status_code == 200 # Get Recipe Data response = api_client.get(Routes.recipe(slug), headers=unique_user.token) - as_json = response.json() - assert as_json["tools"][0]["id"] == tool.id diff --git a/tests/integration_tests/user_group_tests/test_group_cookbooks.py b/tests/integration_tests/user_group_tests/test_group_cookbooks.py index 076bc3a04515..e7ddf35dc44d 100644 --- a/tests/integration_tests/user_group_tests/test_group_cookbooks.py +++ b/tests/integration_tests/user_group_tests/test_group_cookbooks.py @@ -1,8 +1,14 @@ +import random +from dataclasses import dataclass from uuid import UUID +import pytest from fastapi.testclient import TestClient +from mealie.repos.repository_factory import AllRepositories +from mealie.schema.cookbook.cookbook import ReadCookBook, SaveCookBook from tests.utils.assertion_helpers import assert_ignore_keys +from tests.utils.factories import random_string from tests.utils.fixture_schemas import TestUser @@ -14,27 +20,56 @@ class Routes: def get_page_data(group_id: UUID): + name_and_slug = random_string(10) return { - "name": "My New Page", - "slug": "my-new-page", + "name": name_and_slug, + "slug": name_and_slug, "description": "", "position": 0, "categories": [], - "group_id": group_id, + "group_id": str(group_id), } +@dataclass +class TestCookbook: + id: int + slug: str + name: str + data: dict + + +@pytest.fixture(scope="function") +def cookbooks(database: AllRepositories, unique_user: TestUser) -> list[TestCookbook]: + + data: list[ReadCookBook] = [] + yield_data: list[TestCookbook] = [] + for _ in range(3): + cb = database.cookbooks.create(SaveCookBook(**get_page_data(unique_user.group_id))) + data.append(cb) + yield_data.append(TestCookbook(id=cb.id, slug=cb.slug, name=cb.name, data=cb.dict())) + + yield yield_data + + for cb in yield_data: + try: + database.cookbooks.delete(cb.id) + except Exception: + pass + + def test_create_cookbook(api_client: TestClient, unique_user: TestUser): page_data = get_page_data(unique_user.group_id) response = api_client.post(Routes.base, json=page_data, headers=unique_user.token) assert response.status_code == 201 -def test_read_cookbook(api_client: TestClient, unique_user: TestUser): - page_data = get_page_data(unique_user.group_id) +def test_read_cookbook(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]): + sample = random.choice(cookbooks) - response = api_client.get(Routes.item(1), headers=unique_user.token) - assert_ignore_keys(response.json(), page_data) + response = api_client.get(Routes.item(sample.id), headers=unique_user.token) + assert response.status_code == 200 + assert_ignore_keys(response.json(), sample.data) def test_update_cookbook(api_client: TestClient, unique_user: TestUser): @@ -44,14 +79,36 @@ def test_update_cookbook(api_client: TestClient, unique_user: TestUser): page_data["name"] = "My New Name" response = api_client.put(Routes.item(1), json=page_data, headers=unique_user.token) - assert response.status_code == 200 -def test_delete_cookbook(api_client: TestClient, unique_user: TestUser): - response = api_client.delete(Routes.item(1), headers=unique_user.token) +def test_update_cookbooks_many(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]): + pages = [x.data for x in cookbooks] + + reverse_order = sorted(pages, key=lambda x: x["position"], reverse=True) + for x, page in enumerate(reverse_order): + page["position"] = x + page["group_id"] = str(unique_user.group_id) + + response = api_client.put(Routes.base, json=reverse_order, headers=unique_user.token) + assert response.status_code == 200 + + response = api_client.get(Routes.base, headers=unique_user.token) + assert response.status_code == 200 + + known_ids = [x.id for x in cookbooks] + + server_ids = [x["id"] for x in response.json()] + + for know in known_ids: # Hacky check, because other tests don't cleanup after themselves :( + assert know in server_ids + + +def test_delete_cookbook(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]): + sample = random.choice(cookbooks) + response = api_client.delete(Routes.item(sample.id), headers=unique_user.token) assert response.status_code == 200 - response = api_client.get(Routes.item(1), headers=unique_user.token) + response = api_client.get(Routes.item(sample.slug), headers=unique_user.token) assert response.status_code == 404 diff --git a/tests/integration_tests/user_group_tests/test_group_mealplan_preferences.py b/tests/integration_tests/user_group_tests/test_group_mealplan_preferences.py new file mode 100644 index 000000000000..bb20aed8af5d --- /dev/null +++ b/tests/integration_tests/user_group_tests/test_group_mealplan_preferences.py @@ -0,0 +1,38 @@ +from fastapi.testclient import TestClient + +from mealie.repos.all_repositories import AllRepositories +from tests.utils.assertion_helpers import assert_ignore_keys +from tests.utils.fixture_schemas import TestUser + + +class Routes: + base = "/api/groups/categories" + + @staticmethod + def item(item_id: int | str) -> str: + return f"{Routes.base}/{item_id}" + + +def test_group_mealplan_set_preferences(api_client: TestClient, unique_user: TestUser, database: AllRepositories): + # Create Categories + categories = [{"name": x} for x in ["Breakfast", "Lunch", "Dinner"]] + + created = [] + for category in categories: + create = database.categories.create(category) + created.append(create.dict()) + + # Set Category Preferences + response = api_client.put(Routes.base, json=created, headers=unique_user.token) + assert response.status_code == 200 + + # Get Category Preferences + response = api_client.get(Routes.base, headers=unique_user.token) + assert response.status_code == 200 + + as_dict = response.json() + + assert len(as_dict) == len(categories) + + for api_data, expected in zip(as_dict, created): + assert_ignore_keys(api_data, expected, ["id", "recipes"]) diff --git a/tests/integration_tests/user_group_tests/test_group_permissions.py b/tests/integration_tests/user_group_tests/test_group_permissions.py new file mode 100644 index 000000000000..a7d0bcaa356b --- /dev/null +++ b/tests/integration_tests/user_group_tests/test_group_permissions.py @@ -0,0 +1,100 @@ +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from mealie.repos.repository_factory import AllRepositories +from tests.utils.factories import random_bool +from tests.utils.fixture_schemas import TestUser + + +class Routes: + self = "/api/groups/self" + memebers = "/api/groups/members" + permissions = "/api/groups/permissions" + + +def get_permissions_payload(user_id: str, can_manage=None) -> dict: + return { + "user_id": user_id, + "can_manage": random_bool() if can_manage is None else can_manage, + "can_invite": random_bool(), + "can_organize": random_bool(), + } + + +def test_get_group_members(api_client: TestClient, user_tuple: list[TestUser]): + usr_1, usr_2 = user_tuple + + response = api_client.get(Routes.memebers, headers=usr_1.token) + assert response.status_code == 200 + + members = response.json() + assert len(members) >= 2 + + all_ids = [x["id"] for x in members] + + assert str(usr_1.user_id) in all_ids + assert str(usr_2.user_id) in all_ids + + +def test_set_memeber_permissions(api_client: TestClient, user_tuple: list[TestUser], database: AllRepositories): + usr_1, usr_2 = user_tuple + + # Set Acting User + acting_user = database.users.get_one(usr_1.user_id) + acting_user.can_manage = True + database.users.update(acting_user.id, acting_user) + + payload = get_permissions_payload(str(usr_2.user_id)) + + # Test + response = api_client.put(Routes.permissions, json=payload, headers=usr_1.token) + assert response.status_code == 200 + + +def test_set_memeber_permissions_unauthorized(api_client: TestClient, unique_user: TestUser, database: AllRepositories): + # Setup + user = database.users.get_one(unique_user.user_id) + user.can_manage = False + database.users.update(user.id, user) + + payload = get_permissions_payload(str(user.id)) + payload = { + "user_id": str(user.id), + "can_manage": True, + "can_invite": True, + "can_organize": True, + } + + # Test + response = api_client.put(Routes.permissions, json=payload, headers=unique_user.token) + assert response.status_code == 403 + + +def test_set_memeber_permissions_other_group( + api_client: TestClient, + unique_user: TestUser, + g2_user: TestUser, + database: AllRepositories, +): + user = database.users.get_one(unique_user.user_id) + user.can_manage = True + database.users.update(user.id, user) + + payload = get_permissions_payload(str(g2_user.user_id)) + response = api_client.put(Routes.permissions, json=payload, headers=unique_user.token) + assert response.status_code == 403 + + +def test_set_memeber_permissions_no_user( + api_client: TestClient, + unique_user: TestUser, + database: AllRepositories, +): + user = database.users.get_one(unique_user.user_id) + user.can_manage = True + database.users.update(user.id, user) + + payload = get_permissions_payload(str(uuid4())) + response = api_client.put(Routes.permissions, json=payload, headers=unique_user.token) + assert response.status_code == 404 diff --git a/tests/integration_tests/user_group_tests/test_group_preferences.py b/tests/integration_tests/user_group_tests/test_group_preferences.py index b803758fb376..64018a69c188 100644 --- a/tests/integration_tests/user_group_tests/test_group_preferences.py +++ b/tests/integration_tests/user_group_tests/test_group_preferences.py @@ -12,7 +12,6 @@ class Routes: def test_get_preferences(api_client: TestClient, unique_user: TestUser) -> None: response = api_client.get(Routes.preferences, headers=unique_user.token) - assert response.status_code == 200 preferences = response.json() diff --git a/tests/integration_tests/user_group_tests/test_group_recipe_import_export.py b/tests/integration_tests/user_group_tests/test_group_recipe_import_export.py deleted file mode 100644 index de83795baebf..000000000000 --- a/tests/integration_tests/user_group_tests/test_group_recipe_import_export.py +++ /dev/null @@ -1,15 +0,0 @@ -# from fastapi.testclient import TestClient - -# from tests.utils.fixture_schemas import TestUser - - -# class Routes: -# base = "/api/groups/manage/data" # Not sure if this is a good url?!?!? - - -# def test_recipe_export(api_client: TestClient, unique_user: TestUser) -> None: -# assert False - - -# def test_recipe_import(api_client: TestClient, unique_user: TestUser) -> None: -# assert False diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py b/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py new file mode 100644 index 000000000000..25cc15fe006b --- /dev/null +++ b/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py @@ -0,0 +1,160 @@ +from pathlib import Path + +import pytest +import sqlalchemy +from fastapi.testclient import TestClient + +from mealie.core.dependencies.dependencies import validate_file_token +from mealie.repos.repository_factory import AllRepositories +from mealie.schema.recipe.recipe_bulk_actions import ExportTypes +from mealie.schema.recipe.recipe_category import TagIn +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +class Routes: + create_recipes = "/api/recipes" + + bulk_tag = "api/recipes/bulk-actions/tag" + bulk_categorize = "api/recipes/bulk-actions/categorize" + bulk_delete = "api/recipes/bulk-actions/delete" + + bulk_export = "api/recipes/bulk-actions/export" + bulk_export_download = bulk_export + "/download" + bulk_export_purge = bulk_export + "/purge" + + +@pytest.fixture(scope="function") +def ten_slugs(api_client: TestClient, unique_user: TestUser, database: AllRepositories) -> list[str]: + + slugs = [] + + for _ in range(10): + payload = {"name": random_string(length=20)} + response = api_client.post(Routes.create_recipes, json=payload, headers=unique_user.token) + assert response.status_code == 201 + + response_data = response.json() + slugs.append(response_data) + + yield slugs + + for slug in slugs: + try: + database.recipes.delete(slug) + except sqlalchemy.exc.NoResultFound: + pass + + +def test_bulk_tag_recipes( + api_client: TestClient, unique_user: TestUser, database: AllRepositories, ten_slugs: list[str] +): + # Setup Tags + tags = [] + for _ in range(3): + tag_name = random_string() + tag = database.tags.create(TagIn(name=tag_name)) + tags.append(tag.dict()) + + payload = {"recipes": ten_slugs, "tags": tags} + + response = api_client.post(Routes.bulk_tag, json=payload, headers=unique_user.token) + assert response.status_code == 200 + + # Validate Recipes are Tagged + for slug in ten_slugs: + recipe = database.recipes.get_one(slug) + + for tag in recipe.tags: + assert tag.slug in [x["slug"] for x in tags] + + +def test_bulk_categorize_recipes( + api_client: TestClient, + unique_user: TestUser, + database: AllRepositories, + ten_slugs: list[str], +): + # Setup Tags + categories = [] + for _ in range(3): + cat_name = random_string() + cat = database.tags.create(TagIn(name=cat_name)) + categories.append(cat.dict()) + + payload = {"recipes": ten_slugs, "categories": categories} + + response = api_client.post(Routes.bulk_categorize, json=payload, headers=unique_user.token) + assert response.status_code == 200 + + # Validate Recipes are Categorized + for slug in ten_slugs: + recipe = database.recipes.get_one(slug) + + for cat in recipe.recipe_category: + assert cat.slug in [x["slug"] for x in categories] + + +def test_bulk_delete_recipes( + api_client: TestClient, + unique_user: TestUser, + database: AllRepositories, + ten_slugs: list[str], +): + + payload = {"recipes": ten_slugs} + + response = api_client.post(Routes.bulk_delete, json=payload, headers=unique_user.token) + assert response.status_code == 200 + + # Validate Recipes are Tagged + for slug in ten_slugs: + recipe = database.recipes.get_one(slug) + assert recipe is None + + +def test_bulk_export_recipes(api_client: TestClient, unique_user: TestUser, ten_slugs: list[str]): + payload = { + "recipes": ten_slugs, + "export_type": ExportTypes.JSON.value, + } + + response = api_client.post(Routes.bulk_export, json=payload, headers=unique_user.token) + assert response.status_code == 202 + + # Get All Exports Available + response = api_client.get(Routes.bulk_export, headers=unique_user.token) + assert response.status_code == 200 + + response_data = response.json() + assert len(response_data) == 1 + + export_path = response_data[0]["path"] + + # Get Export Token + response = api_client.get(f"{Routes.bulk_export_download}?path={export_path}", headers=unique_user.token) + assert response.status_code == 200 + + response_data = response.json() + + assert validate_file_token(response_data["fileToken"]) == Path(export_path) + + # Use Export Token to donwload export + response = api_client.get("/api/utils/download?token=" + response_data["fileToken"]) + + assert response.status_code == 200 + + # Smoke Test to check that a file was downloaded + assert response.headers["Content-Type"] == "application/octet-stream" + assert len(response.content) > 0 + + # Purge Export + response = api_client.delete(Routes.bulk_export_purge, headers=unique_user.token) + assert response.status_code == 200 + + # Validate Export was purged + response = api_client.get(Routes.bulk_export, headers=unique_user.token) + assert response.status_code == 200 + + response_data = response.json() + assert len(response_data) == 0 diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py index 02743e7ef694..58c6914537fa 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -152,3 +152,17 @@ def test_delete(api_client: TestClient, api_routes: AppRoutes, recipe_data: Reci recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug) response = api_client.delete(recipe_url, headers=unique_user.token) assert response.status_code == 200 + + +def test_recipe_crud_404(api_client: TestClient, api_routes: AppRoutes, unique_user: TestUser): + response = api_client.put(api_routes.recipes_recipe_slug("test"), json={"test": "stest"}, headers=unique_user.token) + assert response.status_code == 404 + + response = api_client.get(api_routes.recipes_recipe_slug("test"), headers=unique_user.token) + assert response.status_code == 404 + + response = api_client.delete(api_routes.recipes_recipe_slug("test"), headers=unique_user.token) + assert response.status_code == 404 + + response = api_client.patch(api_routes.recipes_create_url, json={"test": "stest"}, headers=unique_user.token) + assert response.status_code == 404 diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_favorites.py b/tests/integration_tests/user_recipe_tests/test_recipe_favorites.py new file mode 100644 index 000000000000..71d4534d9367 --- /dev/null +++ b/tests/integration_tests/user_recipe_tests/test_recipe_favorites.py @@ -0,0 +1,66 @@ +import pytest +import sqlalchemy +from fastapi.testclient import TestClient + +from mealie.repos.repository_factory import AllRepositories +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +class Routes: + create_recipes = "/api/recipes" + + def base(item_id: int) -> str: + return f"api/users/{item_id}/favorites" + + def toggle(item_id: int, slug: str) -> str: + return f"{Routes.base(item_id)}/{slug}" + + +@pytest.fixture(scope="function") +def ten_slugs(api_client: TestClient, unique_user: TestUser, database: AllRepositories) -> list[str]: + + slugs = [] + + for _ in range(10): + payload = {"name": random_string(length=20)} + response = api_client.post(Routes.create_recipes, json=payload, headers=unique_user.token) + assert response.status_code == 201 + + response_data = response.json() + slugs.append(response_data) + + yield slugs + + for slug in slugs: + try: + database.recipes.delete(slug) + except sqlalchemy.exc.NoResultFound: + pass + + +def test_recipe_favorites(api_client: TestClient, unique_user: TestUser, ten_slugs: list[str]): + # Check that the user has no favorites + response = api_client.get(Routes.base(unique_user.user_id), headers=unique_user.token) + assert response.status_code == 200 + assert response.json()["favoriteRecipes"] == [] + + # Add a few recipes to the user's favorites + for slug in ten_slugs: + response = api_client.post(Routes.toggle(unique_user.user_id, slug), headers=unique_user.token) + assert response.status_code == 200 + + # Check that the user has the recipes in their favorites + response = api_client.get(Routes.base(unique_user.user_id), headers=unique_user.token) + assert response.status_code == 200 + assert len(response.json()["favoriteRecipes"]) == 10 + + # Remove a few recipes from the user's favorites + for slug in ten_slugs[:5]: + response = api_client.delete(Routes.toggle(unique_user.user_id, slug), headers=unique_user.token) + assert response.status_code == 200 + + # Check that the user has the recipes in their favorites + response = api_client.get(Routes.base(unique_user.user_id), headers=unique_user.token) + assert response.status_code == 200 + assert len(response.json()["favoriteRecipes"]) == 5 diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_ingredient_parser.py b/tests/integration_tests/user_recipe_tests/test_recipe_ingredient_parser.py new file mode 100644 index 000000000000..8b2990b85d1a --- /dev/null +++ b/tests/integration_tests/user_recipe_tests/test_recipe_ingredient_parser.py @@ -0,0 +1,47 @@ +import pytest +from fastapi.testclient import TestClient + +from mealie.schema.recipe.recipe_ingredient import RegisteredParser +from tests.unit_tests.test_ingredient_parser import TestIngredient, crf_exists, test_ingredients +from tests.utils.fixture_schemas import TestUser + + +class Routes: + ingredient = "/api/parser/ingredient" + ingredients = "/api/parser/ingredients" + + +def assert_ingredient(api_response: dict, test_ingredient: TestIngredient): + assert api_response["ingredient"]["quantity"] == test_ingredient.quantity + assert api_response["ingredient"]["unit"]["name"] == test_ingredient.unit + assert api_response["ingredient"]["food"]["name"] == test_ingredient.food + assert api_response["ingredient"]["note"] == test_ingredient.comments + + +@pytest.mark.skipif(not crf_exists(), reason="CRF++ not installed") +@pytest.mark.parametrize("test_ingredient", test_ingredients) +def test_recipe_ingredient_parser_nlp(api_client: TestClient, test_ingredient: TestIngredient, unique_user: TestUser): + payload = {"parser": RegisteredParser.nlp, "ingredient": test_ingredient.input} + response = api_client.post(Routes.ingredient, json=payload, headers=unique_user.token) + assert response.status_code == 200 + assert_ingredient(response.json(), test_ingredient) + + +@pytest.mark.skipif(not crf_exists(), reason="CRF++ not installed") +def test_recipe_ingredients_parser_nlp(api_client: TestClient, unique_user: TestUser): + payload = {"parser": RegisteredParser.nlp, "ingredients": [x.input for x in test_ingredients]} + response = api_client.post(Routes.ingredients, json=payload, headers=unique_user.token) + assert response.status_code == 200 + + for api_ingredient, test_ingredient in zip(response.json(), test_ingredients): + assert_ingredient(api_ingredient, test_ingredient) + + +@pytest.mark.skip("TODO: Implement") +def test_recipe_ingredient_parser_brute(api_client: TestClient): + pass + + +@pytest.mark.skip("TODO: Implement") +def test_recipe_ingredients_parser_brute(api_client: TestClient): + pass diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_share_tokens.py b/tests/integration_tests/user_recipe_tests/test_recipe_share_tokens.py new file mode 100644 index 000000000000..2927b4488c52 --- /dev/null +++ b/tests/integration_tests/user_recipe_tests/test_recipe_share_tokens.py @@ -0,0 +1,125 @@ +import pytest +import sqlalchemy +from fastapi.testclient import TestClient + +from mealie.repos.repository_factory import AllRepositories +from mealie.schema.recipe.recipe_share_token import RecipeShareTokenSave +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +class Routes: + base = "/api/shared/recipes" + create_recipes = "/api/recipes" + + @staticmethod + def item(item_id: str): + return f"{Routes.base}/{item_id}" + + +@pytest.fixture(scope="function") +def slug(api_client: TestClient, unique_user: TestUser, database: AllRepositories) -> str: + + payload = {"name": random_string(length=20)} + response = api_client.post(Routes.create_recipes, json=payload, headers=unique_user.token) + assert response.status_code == 201 + + response_data = response.json() + + yield response_data + + try: + database.recipes.delete(response_data) + except sqlalchemy.exc.NoResultFound: + pass + + +def test_recipe_share_tokens_get_all( + api_client: TestClient, + unique_user: TestUser, + database: AllRepositories, + slug: str, +): + # Create 5 Tokens + recipe = database.recipes.get_one(slug) + tokens = [] + for _ in range(5): + token = database.recipe_share_tokens.create( + RecipeShareTokenSave(recipe_id=recipe.id, group_id=unique_user.group_id) + ) + tokens.append(token) + + # Get All Tokens + response = api_client.get(Routes.base, headers=unique_user.token) + assert response.status_code == 200 + + response_data = response.json() + assert len(response_data) == 5 + + +def test_recipe_share_tokens_get_all_with_id( + api_client: TestClient, + unique_user: TestUser, + database: AllRepositories, + slug: str, +): + # Create 5 Tokens + recipe = database.recipes.get_one(slug) + tokens = [] + for _ in range(3): + token = database.recipe_share_tokens.create( + RecipeShareTokenSave(recipe_id=recipe.id, group_id=unique_user.group_id) + ) + tokens.append(token) + + response = api_client.get(Routes.base + "?recipe_id=" + str(recipe.id), headers=unique_user.token) + assert response.status_code == 200 + + response_data = response.json() + + assert len(response_data) == 3 + + +def test_recipe_share_tokens_create_and_get_one( + api_client: TestClient, + unique_user: TestUser, + database: AllRepositories, + slug: str, +): + recipe = database.recipes.get_one(slug) + + payload = { + "recipe_id": recipe.id, + } + + response = api_client.post(Routes.base, json=payload, headers=unique_user.token) + assert response.status_code == 201 + + response = api_client.get(Routes.item(response.json()["id"]), json=payload, headers=unique_user.token) + assert response.status_code == 200 + + response_data = response.json() + assert response_data["recipe"]["id"] == recipe.id + + +def test_recipe_share_tokens_delete_one( + api_client: TestClient, + unique_user: TestUser, + database: AllRepositories, + slug: str, +): + # Create Token + recipe = database.recipes.get_one(slug) + + token = database.recipe_share_tokens.create( + RecipeShareTokenSave(recipe_id=recipe.id, group_id=unique_user.group_id) + ) + + # Delete Token + response = api_client.delete(Routes.item(token.id), headers=unique_user.token) + assert response.status_code == 200 + + # Get Token + token = database.recipe_share_tokens.get_one(token.id) + + assert token is None diff --git a/tests/integration_tests/user_tests/test_user_login.py b/tests/integration_tests/user_tests/test_user_login.py index c4b4a631e862..31ed05c9abe0 100644 --- a/tests/integration_tests/user_tests/test_user_login.py +++ b/tests/integration_tests/user_tests/test_user_login.py @@ -3,6 +3,7 @@ import json from fastapi.testclient import TestClient from tests.utils.app_routes import AppRoutes +from tests.utils.fixture_schemas import TestUser def test_failed_login(api_client: TestClient, api_routes: AppRoutes): @@ -23,3 +24,9 @@ def test_superuser_login(api_client: TestClient, api_routes: AppRoutes, admin_to assert response.status_code == 200 return {"Authorization": f"Bearer {new_token}"} + + +def test_user_token_refresh(api_client: TestClient, api_routes: AppRoutes, admin_user: TestUser): + response = api_client.post(api_routes.auth_refresh, headers=admin_user.token) + response = api_client.get(api_routes.users_self, headers=admin_user.token) + assert response.status_code == 200 diff --git a/tests/unit_tests/test_exceptions.py b/tests/unit_tests/test_exceptions.py new file mode 100644 index 000000000000..4fe0a3694e37 --- /dev/null +++ b/tests/unit_tests/test_exceptions.py @@ -0,0 +1,12 @@ +from mealie.core import exceptions +from mealie.lang import get_locale_provider + + +def test_mealie_registered_exceptions() -> None: + provider = get_locale_provider() + + lookup = exceptions.mealie_registered_exceptions(provider) + + assert "permission" in lookup[exceptions.PermissionDenied] + assert "The requested resource was not found" in lookup[exceptions.NoEntryFound] + assert "integrity" in lookup[exceptions.IntegrityError] diff --git a/tests/unit_tests/test_ingredient_parser.py b/tests/unit_tests/test_ingredient_parser.py index 6f1942c6d97b..c5804c57e6ba 100644 --- a/tests/unit_tests/test_ingredient_parser.py +++ b/tests/unit_tests/test_ingredient_parser.py @@ -1,3 +1,4 @@ +import shutil from dataclasses import dataclass from fractions import Fraction @@ -17,7 +18,6 @@ class TestIngredient: def crf_exists() -> bool: - import shutil return shutil.which("crf_test") is not None diff --git a/tests/unit_tests/test_recipe_parser.py b/tests/unit_tests/test_recipe_parser.py index b84a67340a52..45cf5585a94a 100644 --- a/tests/unit_tests/test_recipe_parser.py +++ b/tests/unit_tests/test_recipe_parser.py @@ -14,7 +14,7 @@ and then use this test case by removing the `@pytest.mark.skip` and than testing """ -@pytest.mark.skip +@pytest.mark.skipif(True, reason="Long Running API Test - manually run when updating the parser") @pytest.mark.parametrize("recipe_test_data", test_cases) def test_recipe_parser(recipe_test_data: RecipeSiteTestCase): recipe = scraper.create_from_url(recipe_test_data.url) diff --git a/tests/unit_tests/test_utils.py b/tests/unit_tests/test_utils.py new file mode 100644 index 000000000000..cb2d490b26e4 --- /dev/null +++ b/tests/unit_tests/test_utils.py @@ -0,0 +1,22 @@ +import pytest + +from mealie.utils.fs_stats import pretty_size + + +@pytest.mark.parametrize( + "size, expected", + [ + (0, "0 bytes"), + (1, "1 bytes"), + (1024, "1.0 KB"), + (1024 ** 2, "1.0 MB"), + (1024 ** 2 * 1024, "1.0 GB"), + (1024 ** 2 * 1024 * 1024, "1.0 TB"), + ], +) +def test_pretty_size(size: int, expected: str) -> None: + """ + Test pretty size takes in a integer value of a file size and returns the most applicable + file unit and the size. + """ + assert pretty_size(size) == expected