Skip to content

Commit

Permalink
feat: Allow Cookbooks To Share Names (mealie-recipes#4186)
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-genson authored and Choromanski committed Oct 1, 2024
1 parent c9f44d3 commit 3a07984
Show file tree
Hide file tree
Showing 8 changed files with 185 additions and 29 deletions.
1 change: 1 addition & 0 deletions frontend/components/Layout/DefaultLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
20 changes: 10 additions & 10 deletions frontend/components/Layout/LayoutParts/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@
<template v-if="topLink">
<v-list nav dense>
<template v-for="nav in topLink">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.title">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
<!-- Multi Items -->
<v-list-group
v-if="nav.children"
:key="nav.title + 'multi-item'"
:key="(nav.key || nav.title) + 'multi-item'"
v-model="dropDowns[nav.title]"
color="primary"
:prepend-icon="nav.icon"
Expand All @@ -39,7 +39,7 @@
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</template>

<v-list-item v-for="child in nav.children" :key="child.title" exact :to="child.to" class="ml-2">
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to" class="ml-2">
<v-list-item-icon>
<v-icon>{{ child.icon }}</v-icon>
</v-list-item-icon>
Expand All @@ -50,7 +50,7 @@
<!-- Single Item -->
<v-list-item-group
v-else
:key="nav.title + 'single-item'"
:key="(nav.key || nav.title) + 'single-item'"
v-model="secondarySelected"
color="primary"
>
Expand All @@ -71,11 +71,11 @@
<v-divider class="mt-2"></v-divider>
<v-list nav dense exact>
<template v-for="nav in secondaryLinks">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.title">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
<!-- Multi Items -->
<v-list-group
v-if="nav.children"
:key="nav.title + 'multi-item'"
:key="(nav.key || nav.title) + 'multi-item'"
v-model="dropDowns[nav.title]"
color="primary"
:prepend-icon="nav.icon"
Expand All @@ -84,7 +84,7 @@
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</template>

<v-list-item v-for="child in nav.children" :key="child.title" exact :to="child.to">
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to">
<v-list-item-icon>
<v-icon>{{ child.icon }}</v-icon>
</v-list-item-icon>
Expand All @@ -94,7 +94,7 @@
</v-list-group>

<!-- Single Item -->
<v-list-item-group v-else :key="nav.title + 'single-item'" v-model="secondarySelected" color="primary">
<v-list-item-group v-else :key="(nav.key || nav.title) + 'single-item'" v-model="secondarySelected" color="primary">
<v-list-item exact link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
Expand All @@ -112,9 +112,9 @@
<v-list nav dense>
<v-list-item-group v-model="bottomSelected" color="primary">
<template v-for="nav in bottomLinks">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.title">
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
<v-list-item
:key="nav.title"
:key="nav.key || nav.title"
exact
link
:to="nav.to || null"
Expand Down
1 change: 1 addition & 0 deletions frontend/types/application-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface SideBarLink {
key?: string;
icon: string;
to?: string;
href?: string;
Expand Down
66 changes: 66 additions & 0 deletions mealie/repos/repository_cookbooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import re
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]):
def create(self, data: SaveCookBook | dict) -> ReadCookBook:
if isinstance(data, dict):
data = SaveCookBook(**data)
data.slug = slugify(data.name)

max_retries = 10
for i in range(max_retries):
try:
return super().create(data)
except IntegrityError:
self.session.rollback()
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 re.match(f"^({new_slug})(-\d+)?$", data.slug)):
data.slug = new_slug

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)
5 changes: 3 additions & 2 deletions mealie/repos/repository_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)

Expand Down
12 changes: 0 additions & 12 deletions mealie/schema/cookbook/cookbook.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
]
)
Expand Down
98 changes: 98 additions & 0 deletions tests/unit_tests/repository_tests/test_cookbook_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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.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

0 comments on commit 3a07984

Please sign in to comment.