From 26765c3fe19c384ad856ccc12ec536c6dd38dd40 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:08:10 +0000 Subject: [PATCH 1/8] add cookbook repo --- mealie/repos/repository_cookbooks.py | 29 ++++++++++++++++++++++++++++ mealie/repos/repository_factory.py | 5 +++-- 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 mealie/repos/repository_cookbooks.py diff --git a/mealie/repos/repository_cookbooks.py b/mealie/repos/repository_cookbooks.py new file mode 100644 index 00000000000..52333b2631b --- /dev/null +++ b/mealie/repos/repository_cookbooks.py @@ -0,0 +1,29 @@ +from collections.abc import Iterable + +from slugify import slugify +from sqlalchemy.exc import IntegrityError + +from mealie.db.models.household.cookbook import CookBook +from mealie.repos.repository_generic import HouseholdRepositoryGeneric +from mealie.schema.cookbook.cookbook import ReadCookBook, SaveCookBook + + +class RepositoryCookbooks(HouseholdRepositoryGeneric[ReadCookBook, CookBook]): + def create(self, data: SaveCookBook | dict) -> ReadCookBook: + if isinstance(data, dict): + data = SaveCookBook(**data) + data.slug = slugify(data.name) + + max_retries = 10 + original_name = data.name + for i in range(max_retries): + try: + return super().create(data) + except IntegrityError: + self.session.rollback() + data.slug = slugify(f"{original_name} ({i+1})") + + raise # raise the last IntegrityError + + def create_many(self, data: Iterable[ReadCookBook | dict]) -> list[ReadCookBook]: + return [self.create(entry) for entry in data] diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py index bc9b4d67c42..341d62bf930 100644 --- a/mealie/repos/repository_factory.py +++ b/mealie/repos/repository_factory.py @@ -35,6 +35,7 @@ from mealie.db.models.users import LongLiveToken, User from mealie.db.models.users.password_reset import PasswordResetModel from mealie.db.models.users.user_to_recipe import UserToRecipe +from mealie.repos.repository_cookbooks import RepositoryCookbooks from mealie.repos.repository_foods import RepositoryFood from mealie.repos.repository_household import RepositoryHousehold from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules @@ -231,8 +232,8 @@ def household_preferences(self) -> HouseholdRepositoryGeneric[ReadHouseholdPrefe ) @cached_property - def cookbooks(self) -> HouseholdRepositoryGeneric[ReadCookBook, CookBook]: - return HouseholdRepositoryGeneric( + def cookbooks(self) -> RepositoryCookbooks: + return RepositoryCookbooks( self.session, PK_ID, CookBook, ReadCookBook, group_id=self.group_id, household_id=self.household_id ) From cccba96b336e6c7f3590eb437140340ca21583e3 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:09:22 +0000 Subject: [PATCH 2/8] fix overwritten slug --- mealie/schema/cookbook/cookbook.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/mealie/schema/cookbook/cookbook.py b/mealie/schema/cookbook/cookbook.py index 910bd1937bc..00ef3805e96 100644 --- a/mealie/schema/cookbook/cookbook.py +++ b/mealie/schema/cookbook/cookbook.py @@ -1,8 +1,6 @@ from typing import Annotated from pydantic import UUID4, ConfigDict, Field, field_validator -from pydantic_core.core_schema import ValidationInfo -from slugify import slugify from sqlalchemy.orm import joinedload from sqlalchemy.orm.interfaces import LoaderOption @@ -31,16 +29,6 @@ class CreateCookBook(MealieModel): def validate_public(public: bool | None) -> bool: return False if public is None else public - @field_validator("slug", mode="before") - def validate_slug(slug: str, info: ValidationInfo): - name: str = info.data["name"] - calc_slug: str = slugify(name) - - if slug != calc_slug: - slug = calc_slug - - return slug - class SaveCookBook(CreateCookBook): group_id: UUID4 From d69bde1bdfb8de7b99b701ba6abbd6e2be349c10 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:35:09 +0000 Subject: [PATCH 3/8] tests --- .../test_cookbook_repository.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/unit_tests/repository_tests/test_cookbook_repository.py diff --git a/tests/unit_tests/repository_tests/test_cookbook_repository.py b/tests/unit_tests/repository_tests/test_cookbook_repository.py new file mode 100644 index 00000000000..f465a6940e7 --- /dev/null +++ b/tests/unit_tests/repository_tests/test_cookbook_repository.py @@ -0,0 +1,53 @@ +from uuid import UUID + +import pytest +from slugify import slugify + +from mealie.schema.cookbook.cookbook import SaveCookBook +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +def cookbook_data(user: TestUser, **kwargs): + data = { + "name": random_string(), + "group_id": UUID(user.group_id), + "household_id": UUID(user.household_id), + } | kwargs + + return SaveCookBook(**data) + + +@pytest.mark.parametrize("use_create_many", [True, False]) +def test_create_cookbook_ignores_slug(unique_user: TestUser, use_create_many: bool): + bad_slug = random_string() + cb_data = cookbook_data(unique_user, slug=bad_slug) + if use_create_many: + result = unique_user.repos.cookbooks.create_many([cb_data]) + assert len(result) == 1 + cb = result[0] + else: + cb = unique_user.repos.cookbooks.create(cb_data) + assert cb.slug == slugify(cb.name) != bad_slug + + +@pytest.mark.parametrize("use_create_many", [True, False]) +def test_create_cookbook_duplicate_name(unique_user: TestUser, use_create_many: bool): + cb_1_data = cookbook_data(unique_user) + cb_2_data = cookbook_data(unique_user, name=cb_1_data.name) + + cb_1 = unique_user.repos.cookbooks.create(cb_1_data) + unique_user.repos.session.commit() + + if use_create_many: + result = unique_user.repos.cookbooks.create_many([cb_2_data]) + assert len(result) == 1 + cb_2 = result[0] + else: + cb_2 = unique_user.repos.cookbooks.create(cb_2_data) + + assert cb_1.id != cb_2.id + assert cb_1.group_id == cb_2.group_id + assert cb_1.household_id != cb_2.household_id + assert cb_1.name == cb_2.name + assert cb_1.slug != cb_2.slug From 7d3986dd2d36bc1ce8f1328fe9701254999f335f Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:48:05 +0000 Subject: [PATCH 4/8] handle slug updates --- mealie/repos/repository_cookbooks.py | 40 ++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/mealie/repos/repository_cookbooks.py b/mealie/repos/repository_cookbooks.py index 52333b2631b..5469c8de459 100644 --- a/mealie/repos/repository_cookbooks.py +++ b/mealie/repos/repository_cookbooks.py @@ -1,11 +1,14 @@ from collections.abc import Iterable +from fastapi import HTTPException, status +from pydantic import UUID4 from slugify import slugify from sqlalchemy.exc import IntegrityError from mealie.db.models.household.cookbook import CookBook from mealie.repos.repository_generic import HouseholdRepositoryGeneric from mealie.schema.cookbook.cookbook import ReadCookBook, SaveCookBook +from mealie.schema.response.responses import ErrorResponse class RepositoryCookbooks(HouseholdRepositoryGeneric[ReadCookBook, CookBook]): @@ -15,15 +18,48 @@ def create(self, data: SaveCookBook | dict) -> ReadCookBook: data.slug = slugify(data.name) max_retries = 10 - original_name = data.name for i in range(max_retries): try: return super().create(data) except IntegrityError: self.session.rollback() - data.slug = slugify(f"{original_name} ({i+1})") + data.slug = slugify(f"{data.name} ({i+1})") raise # raise the last IntegrityError def create_many(self, data: Iterable[ReadCookBook | dict]) -> list[ReadCookBook]: return [self.create(entry) for entry in data] + + def update(self, match_value: str | int | UUID4, data: SaveCookBook | dict) -> ReadCookBook: + if isinstance(data, dict): + data = SaveCookBook(**data) + + new_slug = slugify(data.name) + if not (data.slug and data.slug.startswith(new_slug)): + data.slug = slugify(data.name) + + max_retries = 10 + for i in range(max_retries): + try: + return super().update(match_value, data) + except IntegrityError: + self.session.rollback() + data.slug = slugify(f"{data.name} ({i+1})") + + raise # raise the last IntegrityError + + def update_many(self, data: Iterable[ReadCookBook | dict]) -> list[ReadCookBook]: + return [self.update(entry.id if isinstance(entry, ReadCookBook) else entry["id"], entry) for entry in data] + + def patch(self, match_value: str | int | UUID4, data: SaveCookBook | dict) -> ReadCookBook: + cookbook = self.get_one(match_value) + if not cookbook: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=ErrorResponse.respond(message="Not found."), + ) + cookbook_data = cookbook.model_dump() + + if not isinstance(data, dict): + data = data.model_dump() + return self.update(match_value, cookbook_data | data) From edb248a9d73c82e273b91a77ae8f8a9859c7942f Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:48:08 +0000 Subject: [PATCH 5/8] tests --- .../test_cookbook_repository.py | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/repository_tests/test_cookbook_repository.py b/tests/unit_tests/repository_tests/test_cookbook_repository.py index f465a6940e7..abec4d12aaf 100644 --- a/tests/unit_tests/repository_tests/test_cookbook_repository.py +++ b/tests/unit_tests/repository_tests/test_cookbook_repository.py @@ -47,7 +47,52 @@ def test_create_cookbook_duplicate_name(unique_user: TestUser, use_create_many: cb_2 = unique_user.repos.cookbooks.create(cb_2_data) assert cb_1.id != cb_2.id - assert cb_1.group_id == cb_2.group_id - assert cb_1.household_id != cb_2.household_id + assert cb_1.name == cb_2.name + assert cb_1.slug != cb_2.slug + + +@pytest.mark.parametrize("method", ["update", "update_many", "patch"]) +def test_update_cookbook_updates_slug(unique_user: TestUser, method: str): + cb_data = cookbook_data(unique_user) + cb = unique_user.repos.cookbooks.create(cb_data) + unique_user.repos.session.commit() + + new_name = random_string() + cb.name = new_name + + if method == "update": + cb = unique_user.repos.cookbooks.update(cb.id, cb) + if method == "update_many": + result = unique_user.repos.cookbooks.update_many([cb]) + assert len(result) == 1 + cb = result[0] + else: + cb = unique_user.repos.cookbooks.patch(cb.id, cb) + + assert cb.name == new_name + assert cb.slug == slugify(new_name) + + +@pytest.mark.parametrize("method", ["update", "update_many", "patch"]) +def test_update_cookbook_duplicate_name(unique_user: TestUser, method: str): + cb_1_data = cookbook_data(unique_user) + cb_2_data = cookbook_data(unique_user) + + cb_1 = unique_user.repos.cookbooks.create(cb_1_data) + unique_user.repos.session.commit() + cb_2 = unique_user.repos.cookbooks.create(cb_2_data) + unique_user.repos.session.commit() + + cb_2.name = cb_1.name + if method == "update": + cb_2 = unique_user.repos.cookbooks.update(cb_2.id, cb_2) + if method == "update_many": + result = unique_user.repos.cookbooks.update_many([cb_2]) + assert len(result) == 1 + cb_2 = result[0] + else: + cb_2 = unique_user.repos.cookbooks.patch(cb_2.id, cb_2) + + assert cb_1.id != cb_2.id assert cb_1.name == cb_2.name assert cb_1.slug != cb_2.slug From 1496de52d15a8ce5ac620300e52ee12ff860bbad Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 9 Sep 2024 21:09:48 +0000 Subject: [PATCH 6/8] idk how this used to work --- .../public_explorer_tests/test_public_cookbooks.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py b/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py index b273ee4f31f..4e401785ee6 100644 --- a/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py +++ b/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py @@ -34,11 +34,12 @@ def test_get_all_cookbooks( household_private_map: dict[UUID4, bool] = {} public_cookbooks: list[ReadCookBook] = [] private_cookbooks: list[ReadCookBook] = [] - for database, is_private_household in [ - (unique_user.repos, is_household_1_private), - (h2_user.repos, is_household_2_private), + for user, is_private_household in [ + (unique_user, is_household_1_private), + (h2_user, is_household_2_private), ]: - household = database.households.get_one(unique_user.household_id) + database = user.repos + household = database.households.get_one(user.household_id) assert household and household.preferences household_private_map[household.id] = is_private_household @@ -49,7 +50,7 @@ def test_get_all_cookbooks( ## Set Up Cookbooks default_cookbooks = database.cookbooks.create_many( [ - SaveCookBook(name=random_string(), group_id=unique_user.group_id, household_id=unique_user.household_id) + SaveCookBook(name=random_string(), group_id=user.group_id, household_id=user.household_id) for _ in range(random_int(15, 20)) ] ) From 8eef14380f90c1f52c339104456fd3d63d5af607 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 12 Sep 2024 16:27:52 +0000 Subject: [PATCH 7/8] fix duplicate key in sidebar --- frontend/components/Layout/DefaultLayout.vue | 1 + .../Layout/LayoutParts/AppSidebar.vue | 20 +++++++++---------- frontend/types/application-types.ts | 1 + 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/frontend/components/Layout/DefaultLayout.vue b/frontend/components/Layout/DefaultLayout.vue index cc2aa5c00bc..062a70ed350 100644 --- a/frontend/components/Layout/DefaultLayout.vue +++ b/frontend/components/Layout/DefaultLayout.vue @@ -117,6 +117,7 @@ export default defineComponent({ if (!cookbooks.value) return []; return cookbooks.value.map((cookbook) => { return { + key: cookbook.slug, icon: $globals.icons.pages, title: cookbook.name, to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`, diff --git a/frontend/components/Layout/LayoutParts/AppSidebar.vue b/frontend/components/Layout/LayoutParts/AppSidebar.vue index 049b71e4b8f..44d86f6d586 100644 --- a/frontend/components/Layout/LayoutParts/AppSidebar.vue +++ b/frontend/components/Layout/LayoutParts/AppSidebar.vue @@ -26,11 +26,11 @@