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