From b2066dfe727b6581cac455ecff66716d80025be4 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sat, 21 May 2022 10:23:55 -0800 Subject: [PATCH] feat: add initial notification support * Add updated recipe notification * Add recipe deleted notification * Add notifications translations * Shopping lists full c/u/d notifications * Add categories c/u/d notifications * Deal with None values in translation provider * Add tag c/u/d notifications * Add cookbook c/u/d notifications * use single key pairs for consistency with frontend * change dependency injection strategy * use generic update messages * use service to manage url generation server-side * use new strategies for messages * fix translator Co-authored-by: Miroito --- mealie/lang/messages/en-US.json | 7 +++ mealie/pkgs/i18n/json_provider.py | 4 ++ mealie/routes/_base/base_controllers.py | 14 ++++++ mealie/routes/groups/controller_cookbooks.py | 36 ++++++++++++++-- .../groups/controller_shopping_lists.py | 25 +++++++++-- .../organizers/controller_categories.py | 42 ++++++++++++++++-- mealie/routes/organizers/controller_tags.py | 43 +++++++++++++++++-- mealie/routes/recipe/recipe_crud_routes.py | 42 +++++++++++++++++- mealie/routes/users/registration.py | 10 ++++- mealie/services/urls/__init__.py | 1 + mealie/services/urls/url_constructors.py | 36 ++++++++++++++++ .../user_services/registration_service.py | 8 ++-- 12 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 mealie/services/urls/__init__.py create mode 100644 mealie/services/urls/url_constructors.py diff --git a/mealie/lang/messages/en-US.json b/mealie/lang/messages/en-US.json index 54db43daef80..f60caf6acde3 100644 --- a/mealie/lang/messages/en-US.json +++ b/mealie/lang/messages/en-US.json @@ -11,5 +11,12 @@ "integrity-error": "Database integrity error", "username-conflict-error": "This username is already taken", "email-conflict-error": "This email is already in use" + }, + "notifications": { + "generic-created": "{name} was created", + "generic-updated": "{name} was updated", + "generic-created-with-url": "{name} has been created, {url}", + "generic-updated-with-url": "{name} has been updated, {url}", + "generic-deleted": "{name} has been created" } } diff --git a/mealie/pkgs/i18n/json_provider.py b/mealie/pkgs/i18n/json_provider.py index d980ec963bc6..34cc7710c458 100644 --- a/mealie/pkgs/i18n/json_provider.py +++ b/mealie/pkgs/i18n/json_provider.py @@ -29,6 +29,10 @@ class JsonProvider: break if i == last: + for key, value in kwargs.items(): + if not value: + value = "" + translation_value = translation_value.replace("{" + key + "}", value) return translation_value return default or key diff --git a/mealie/routes/_base/base_controllers.py b/mealie/routes/_base/base_controllers.py index acfaf474fbd5..dac6f073ae06 100644 --- a/mealie/routes/_base/base_controllers.py +++ b/mealie/routes/_base/base_controllers.py @@ -5,6 +5,8 @@ from fastapi import Depends from pydantic import UUID4 from mealie.core.exceptions import mealie_registered_exceptions +from mealie.lang import local_provider +from mealie.lang.providers import Translator from mealie.repos.all_repositories import AllRepositories from mealie.routes._base.checks import OperationChecks from mealie.routes._base.dependencies import SharedDependencies @@ -19,6 +21,10 @@ class BasePublicController(ABC): """ deps: SharedDependencies = Depends(SharedDependencies.public) + translator: Translator = Depends(local_provider) + + def __init__(self): + self.t = self.translator.t if self.translator else local_provider().t class BaseUserController(ABC): @@ -29,6 +35,10 @@ class BaseUserController(ABC): """ deps: SharedDependencies = Depends(SharedDependencies.user) + translator: Translator = Depends(local_provider) + + def __init__(self): + self.t = self.translator.t if self.translator else local_provider().t def registered_exceptions(self, ex: type[Exception]) -> str: registered = { @@ -65,3 +75,7 @@ class BaseAdminController(BaseUserController): """ deps: SharedDependencies = Depends(SharedDependencies.admin) + translator: Translator = Depends(local_provider) + + def __init__(self): + self.t = self.translator.t if self.translator else local_provider().t diff --git a/mealie/routes/groups/controller_cookbooks.py b/mealie/routes/groups/controller_cookbooks.py index c478932bceca..0260baa30643 100644 --- a/mealie/routes/groups/controller_cookbooks.py +++ b/mealie/routes/groups/controller_cookbooks.py @@ -1,6 +1,6 @@ from functools import cached_property -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import UUID4 from mealie.core.exceptions import mealie_registered_exceptions @@ -8,12 +8,17 @@ from mealie.routes._base import BaseUserController, controller from mealie.routes._base.mixins import HttpRepo from mealie.schema import mapper from mealie.schema.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook +from mealie.services.event_bus_service.event_bus_service import EventBusService +from mealie.services.event_bus_service.message_types import EventTypes router = APIRouter(prefix="/groups/cookbooks", tags=["Groups: Cookbooks"]) @controller(router) class GroupCookbookController(BaseUserController): + + event_bus: EventBusService = Depends(EventBusService) + @cached_property def repo(self): return self.deps.repos.cookbooks.by_group(self.group_id) @@ -41,7 +46,15 @@ class GroupCookbookController(BaseUserController): @router.post("", response_model=ReadCookBook, 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) + val = self.mixins.create_one(data) + + if val: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.cookbook_created, + msg=self.t("notifications.generic-created", name=val.name), + ) + return val @router.put("", response_model=list[ReadCookBook]) def update_many(self, data: list[UpdateCookBook]): @@ -75,8 +88,23 @@ class GroupCookbookController(BaseUserController): @router.put("/{item_id}", response_model=ReadCookBook) def update_one(self, item_id: str, data: CreateCookBook): - return self.mixins.update_one(data, item_id) # type: ignore + val = self.mixins.update_one(data, item_id) # type: ignore + if val: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.cookbook_updated, + msg=self.t("notifications.generic-updated", name=val.name), + ) + + return val @router.delete("/{item_id}", response_model=ReadCookBook) def delete_one(self, item_id: str): - return self.mixins.delete_one(item_id) + val = self.mixins.delete_one(item_id) + if val: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.cookbook_deleted, + msg=self.t("notifications.generic-deleted", name=val.name), + ) + return val diff --git a/mealie/routes/groups/controller_shopping_lists.py b/mealie/routes/groups/controller_shopping_lists.py index 1062d69a48c7..b562d8139a76 100644 --- a/mealie/routes/groups/controller_shopping_lists.py +++ b/mealie/routes/groups/controller_shopping_lists.py @@ -28,6 +28,9 @@ item_router = APIRouter(prefix="/groups/shopping/items", tags=["Group: Shopping @controller(item_router) class ShoppingListItemController(BaseUserController): + + event_bus: EventBusService = Depends(EventBusService) + @cached_property def service(self): return ShoppingListService(self.repos) @@ -106,7 +109,7 @@ class ShoppingListController(BaseUserController): # CRUD Operations @cached_property - def mixins(self) -> HttpRepo: + def mixins(self) -> HttpRepo[ShoppingListCreate, ShoppingListOut, ShoppingListSave]: return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") @router.get("", response_model=list[ShoppingListSummary]) @@ -122,7 +125,7 @@ class ShoppingListController(BaseUserController): self.event_bus.dispatch( self.deps.acting_user.group_id, EventTypes.shopping_list_created, - msg="A new shopping list has been created.", + msg=self.t("notifications.generic-created", name=val.name), ) return val @@ -133,11 +136,25 @@ class ShoppingListController(BaseUserController): @router.put("/{item_id}", response_model=ShoppingListOut) def update_one(self, item_id: UUID4, data: ShoppingListUpdate): - return self.mixins.update_one(data, item_id) + data = self.mixins.update_one(data, item_id) # type: ignore + if data: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.shopping_list_updated, + msg=self.t("notifications.generic-updated", name=data.name), + ) + return data @router.delete("/{item_id}", response_model=ShoppingListOut) def delete_one(self, item_id: UUID4): - return self.mixins.delete_one(item_id) # type: ignore + data = self.mixins.delete_one(item_id) # type: ignore + if data: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.shopping_list_updated, + msg=self.t("notifications.generic-deleted", name=data.name), + ) + return data # ======================================================================= # Other Operations diff --git a/mealie/routes/organizers/controller_categories.py b/mealie/routes/organizers/controller_categories.py index c9c1ee068454..01ef7729f527 100644 --- a/mealie/routes/organizers/controller_categories.py +++ b/mealie/routes/organizers/controller_categories.py @@ -1,6 +1,6 @@ from functools import cached_property -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import UUID4, BaseModel from mealie.routes._base import BaseUserController, controller @@ -9,6 +9,9 @@ from mealie.schema import mapper from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse from mealie.schema.recipe.recipe import RecipeCategory from mealie.schema.recipe.recipe_category import CategoryBase, CategorySave +from mealie.services import urls +from mealie.services.event_bus_service.event_bus_service import EventBusService +from mealie.services.event_bus_service.message_types import EventTypes router = APIRouter(prefix="/categories", tags=["Organizer: Categories"]) @@ -24,6 +27,9 @@ class CategorySummary(BaseModel): @controller(router) class RecipeCategoryController(BaseUserController): + + event_bus: EventBusService = Depends(EventBusService) + # ========================================================================= # CRUD Operations @cached_property @@ -43,7 +49,18 @@ class RecipeCategoryController(BaseUserController): def create_one(self, category: CategoryIn): """Creates a Category in the database""" save_data = mapper.cast(category, CategorySave, group_id=self.group_id) - return self.mixins.create_one(save_data) + data = self.mixins.create_one(save_data) + if data: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.category_created, + msg=self.t( + "notifications.generic-created-with-url", + name=data.name, + url=urls.category_url(data.slug, self.deps.settings.BASE_URL), + ), + ) + return data @router.get("/{item_id}", response_model=CategorySummary) def get_one(self, item_id: UUID4): @@ -56,7 +73,19 @@ class RecipeCategoryController(BaseUserController): def update_one(self, item_id: UUID4, update_data: CategoryIn): """Updates an existing Tag in the database""" save_data = mapper.cast(update_data, CategorySave, group_id=self.group_id) - return self.mixins.update_one(save_data, item_id) + data = self.mixins.update_one(save_data, item_id) + + if data: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.category_updated, + msg=self.t( + "notifications.generic-updated-with-url", + name=data.name, + url=urls.category_url(data.slug, self.deps.settings.BASE_URL), + ), + ) + return data @router.delete("/{item_id}") def delete_one(self, item_id: UUID4): @@ -65,7 +94,12 @@ class RecipeCategoryController(BaseUserController): category does not impact a recipe. The category will be removed from any recipes that contain it """ - self.mixins.delete_one(item_id) + if data := self.mixins.delete_one(item_id): + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.category_deleted, + msg=self.t("notifications.generic-deleted", name=data.name), + ) # ========================================================================= # Read All Operations diff --git a/mealie/routes/organizers/controller_tags.py b/mealie/routes/organizers/controller_tags.py index 20fa8ab062b6..f779909ecd3f 100644 --- a/mealie/routes/organizers/controller_tags.py +++ b/mealie/routes/organizers/controller_tags.py @@ -1,6 +1,6 @@ from functools import cached_property -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import UUID4 from mealie.routes._base import BaseUserController, controller @@ -9,12 +9,18 @@ from mealie.schema import mapper from mealie.schema.recipe import RecipeTagResponse, TagIn from mealie.schema.recipe.recipe import RecipeTag from mealie.schema.recipe.recipe_category import TagSave +from mealie.services import urls +from mealie.services.event_bus_service.event_bus_service import EventBusService +from mealie.services.event_bus_service.message_types import EventTypes router = APIRouter(prefix="/tags", tags=["Organizer: Tags"]) @controller(router) class TagController(BaseUserController): + + event_bus: EventBusService = Depends(EventBusService) + @cached_property def repo(self): return self.repos.tags.by_group(self.group_id) @@ -42,13 +48,35 @@ class TagController(BaseUserController): def create_one(self, tag: TagIn): """Creates a Tag in the database""" save_data = mapper.cast(tag, TagSave, group_id=self.group_id) - return self.repo.create(save_data) + data = self.repo.create(save_data) + if data: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.tag_created, + msg=self.t( + "notifications.generic-created-with-url", + name=data.name, + url=urls.tag_url(data.slug, self.deps.settings.BASE_URL), + ), + ) + return data @router.put("/{item_id}", response_model=RecipeTagResponse) def update_one(self, item_id: UUID4, new_tag: TagIn): """Updates an existing Tag in the database""" save_data = mapper.cast(new_tag, TagSave, group_id=self.group_id) - return self.repo.update(item_id, save_data) + data = self.repo.update(item_id, save_data) + if data: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.tag_updated, + msg=self.t( + "notifications.generic-updated-with-url", + name=data.name, + url=urls.tag_url(data.slug, self.deps.settings.BASE_URL), + ), + ) + return data @router.delete("/{item_id}") def delete_recipe_tag(self, item_id: UUID4): @@ -57,10 +85,17 @@ class TagController(BaseUserController): from any recipes that contain it""" try: - self.repo.delete(item_id) + data = self.repo.delete(item_id) except Exception as e: raise HTTPException(status.HTTP_400_BAD_REQUEST) from e + if data: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.tag_deleted, + msg=self.t("notifications.generic-deleted", name=data.name), + ) + @router.get("/slug/{tag_slug}", response_model=RecipeTagResponse) async def get_one_by_slug(self, tag_slug: str): return self.repo.get_one(tag_slug, "slug", override_schema=RecipeTagResponse) diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 37a8f4350031..61bf83822010 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -29,6 +29,9 @@ from mealie.schema.recipe.recipe_asset import RecipeAsset from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest from mealie.schema.response.responses import ErrorResponse from mealie.schema.server.tasks import ServerTaskNames +from mealie.services import urls +from mealie.services.event_bus_service.event_bus_service import EventBusService +from mealie.services.event_bus_service.message_types import EventTypes from mealie.services.recipe.recipe_data_service import RecipeDataService from mealie.services.recipe.recipe_service import RecipeService from mealie.services.recipe.template_service import TemplateService @@ -120,6 +123,8 @@ router = UserAPIRouter(prefix="/recipes", tags=["Recipe: CRUD"]) @controller(router) class RecipeController(BaseRecipeController): + event_bus: EventBusService = Depends(EventBusService) + def handle_exceptions(self, ex: Exception) -> None: match type(ex): case exceptions.PermissionDenied: @@ -152,7 +157,20 @@ class RecipeController(BaseRecipeController): recipe.tags = extras.use_tags(ctx) # type: ignore - return self.service.create_one(recipe).slug + new_recipe = self.service.create_one(recipe) + + if new_recipe: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.recipe_created, + msg=self.t( + "notifications.generic-created-with-url", + name=new_recipe.name, + url=urls.recipe_url(new_recipe.slug, self.deps.settings.BASE_URL), + ), + ) + + return new_recipe.slug @router.post("/create-url/bulk", status_code=202) def parse_recipe_url_bulk(self, bulk: CreateRecipeByUrlBulk, bg_tasks: BackgroundTasks): @@ -249,6 +267,17 @@ class RecipeController(BaseRecipeController): except Exception as e: self.handle_exceptions(e) + if data: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.recipe_updated, + msg=self.t( + "notifications.generic-updated-with-url", + name=data.name, + url=urls.recipe_url(data.slug, self.deps.settings.BASE_URL), + ), + ) + return data @router.patch("/{slug}") @@ -264,10 +293,19 @@ class RecipeController(BaseRecipeController): def delete_one(self, slug: str): """Deletes a recipe by slug""" try: - return self.service.delete_one(slug) + data = self.service.delete_one(slug) except Exception as e: self.handle_exceptions(e) + if data: + self.event_bus.dispatch( + self.deps.acting_user.group_id, + EventTypes.recipe_deleted, + msg=self.t("notifications.generic-deleted", name=data.name), + ) + + return data + # ================================================================================================================== # Image and Assets diff --git a/mealie/routes/users/registration.py b/mealie/routes/users/registration.py index 227b025c9697..0033f4072e37 100644 --- a/mealie/routes/users/registration.py +++ b/mealie/routes/users/registration.py @@ -19,8 +19,14 @@ class RegistrationController(BasePublicController): if not settings.ALLOW_SIGNUP and data.group_token is None or data.group_token == "": raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ErrorResponse.respond("User Registration is Disabled") + status_code=status.HTTP_403_FORBIDDEN, + detail=ErrorResponse.respond("User Registration is Disabled"), ) - registration_service = RegistrationService(self.deps.logger, get_repositories(self.deps.session), self.deps.t) + registration_service = RegistrationService( + self.deps.logger, + get_repositories(self.deps.session), + self.translator, + ) + return registration_service.register_user(data) diff --git a/mealie/services/urls/__init__.py b/mealie/services/urls/__init__.py new file mode 100644 index 000000000000..4364d22ea1a1 --- /dev/null +++ b/mealie/services/urls/__init__.py @@ -0,0 +1 @@ +from .url_constructors import * diff --git a/mealie/services/urls/url_constructors.py b/mealie/services/urls/url_constructors.py new file mode 100644 index 000000000000..4f64f4061b79 --- /dev/null +++ b/mealie/services/urls/url_constructors.py @@ -0,0 +1,36 @@ +from pydantic import UUID4 + +from mealie.core.config import get_app_settings + + +def _base_or(base_url: str | None) -> str: + if base_url is None: + settings = get_app_settings() + return settings.BASE_URL + + return base_url + + +def recipe_url(recipe_slug: str, base_url: str | None) -> str: + base = _base_or(base_url) + return f"{base}/recipe/{recipe_slug}" + + +def shopping_list_url(shopping_list_id: UUID4 | str, base_url: str | None) -> str: + base = _base_or(base_url) + return f"{base}/shopping-list/{shopping_list_id}" + + +def tag_url(tag_slug: str, base_url: str | None) -> str: + base = _base_or(base_url) + return f"{base}/recipes/tags/{tag_slug}" + + +def category_url(category_slug: str, base_url: str | None) -> str: + base = _base_or(base_url) + return f"{base}/recipes/categories/{category_slug}" + + +def tool_url(tool_slug: str, base_url: str | None) -> str: + base = _base_or(base_url) + return f"{base}/recipes/tool/{tool_slug}" diff --git a/mealie/services/user_services/registration_service.py b/mealie/services/user_services/registration_service.py index 01422489523e..34e608cc2dd4 100644 --- a/mealie/services/user_services/registration_service.py +++ b/mealie/services/user_services/registration_service.py @@ -17,10 +17,10 @@ class RegistrationService: logger: Logger repos: AllRepositories - def __init__(self, logger: Logger, db: AllRepositories, t: Translator): + def __init__(self, logger: Logger, db: AllRepositories, translator: Translator): self.logger = logger self.repos = db - self.t = t + self.t = translator.t def _create_new_user(self, group: GroupInDB, new_group: bool) -> PrivateUser: new_user = UserIn( @@ -58,9 +58,9 @@ class RegistrationService: self.registration = registration if self.repos.users.get_by_username(registration.username): - raise HTTPException(status.HTTP_409_CONFLICT, {"message": self.t.t("exceptions.username-conflict-error")}) + raise HTTPException(status.HTTP_409_CONFLICT, {"message": self.t("exceptions.username-conflict-error")}) elif self.repos.users.get(registration.email, "email"): - raise HTTPException(status.HTTP_409_CONFLICT, {"message": self.t.t("exceptions.email-conflict-error")}) + raise HTTPException(status.HTTP_409_CONFLICT, {"message": self.t("exceptions.email-conflict-error")}) self.logger.info(f"Registering user {registration.username}") token_entry = None