1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-19 13:19:41 +02:00

feat: Show Cookbooks from Other Households (#4452)

This commit is contained in:
Michael Genson 2024-11-05 13:57:30 -06:00 committed by GitHub
parent 8983745106
commit 87f4b23711
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 264 additions and 55 deletions

View file

@ -7,7 +7,7 @@
width="100%" width="100%"
max-width="1100px" max-width="1100px"
:icon="$globals.icons.pages" :icon="$globals.icons.pages"
:title="$t('general.edit')" :title="$tc('general.edit')"
:submit-icon="$globals.icons.save" :submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')" :submit-text="$tc('general.save')"
:submit-disabled="!editTarget.queryFilterString" :submit-disabled="!editTarget.queryFilterString"
@ -25,7 +25,7 @@
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title> <v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<BaseButton <BaseButton
v-if="isOwnGroup" v-if="canEdit"
class="mx-1" class="mx-1"
:edit="true" :edit="true"
@click="handleEditCookbook" @click="handleEditCookbook"
@ -79,6 +79,15 @@
const tab = ref(null); const tab = ref(null);
const book = getOne(slug); const book = getOne(slug);
const isOwnHousehold = computed(() => {
if (!($auth.user && book.value?.householdId)) {
return false;
}
return $auth.user.householdId === book.value.householdId;
})
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
const dialogStates = reactive({ const dialogStates = reactive({
edit: false, edit: false,
}); });
@ -118,7 +127,7 @@
recipes, recipes,
removeRecipe, removeRecipe,
replaceRecipes, replaceRecipes,
isOwnGroup, canEdit,
dialogStates, dialogStates,
editTarget, editTarget,
handleEditCookbook, handleEditCookbook,

View file

@ -82,12 +82,17 @@ import { computed, defineComponent, onMounted, ref, useContext, useRoute } from
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue"; import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue"; import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
import { SidebarLinks } from "~/types/application-types"; import { SideBarLink } from "~/types/application-types";
import LanguageDialog from "~/components/global/LanguageDialog.vue"; import LanguageDialog from "~/components/global/LanguageDialog.vue";
import TheSnackbar from "@/components/Layout/LayoutParts/TheSnackbar.vue"; import TheSnackbar from "@/components/Layout/LayoutParts/TheSnackbar.vue";
import { useAppInfo } from "~/composables/api"; import { useAppInfo } from "~/composables/api";
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks"; import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
import { useCookbookPreferences } from "~/composables/use-users/preferences";
import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store";
import { useToggleDarkMode } from "~/composables/use-utils"; import { useToggleDarkMode } from "~/composables/use-utils";
import { ReadCookBook } from "~/lib/api/types/cookbook";
import { HouseholdSummary } from "~/lib/api/types/household";
export default defineComponent({ export default defineComponent({
components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar }, components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar },
@ -99,6 +104,15 @@ export default defineComponent({
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || ""); const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const { cookbooks } = isOwnGroup.value ? useCookbooks() : usePublicCookbooks(groupSlug.value || ""); const { cookbooks } = isOwnGroup.value ? useCookbooks() : usePublicCookbooks(groupSlug.value || "");
const cookbookPreferences = useCookbookPreferences();
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value || "");
const householdsById = computed(() => {
return households.value.reduce((acc, household) => {
acc[household.id] = household;
return acc;
}, {} as { [key: string]: HouseholdSummary });
});
const appInfo = useAppInfo(); const appInfo = useAppInfo();
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices); const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
@ -113,29 +127,57 @@ export default defineComponent({
sidebar.value = !$vuetify.breakpoint.md; sidebar.value = !$vuetify.breakpoint.md;
}); });
const cookbookLinks = computed(() => { function cookbookAsLink(cookbook: ReadCookBook): SideBarLink {
if (!cookbooks.value) return []; return {
return cookbooks.value.map((cookbook) => { key: cookbook.slug || "",
return { icon: $globals.icons.pages,
key: cookbook.slug, title: cookbook.name,
icon: $globals.icons.pages, to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug || ""}`,
title: cookbook.name, restricted: false,
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`, };
};
});
});
interface Link {
insertDivider: boolean;
icon: string;
title: string;
subtitle: string | null;
to: string;
restricted: boolean;
hide: boolean;
} }
const createLinks = computed<Link[]>(() => [ const currentUserHouseholdId = computed(() => $auth.user?.householdId);
const cookbookLinks = computed<SideBarLink[]>(() => {
if (!cookbooks.value) {
return [];
}
cookbooks.value.sort((a, b) => (a.position || 0) - (b.position || 0));
const ownLinks: SideBarLink[] = [];
const links: SideBarLink[] = [];
const cookbooksByHousehold = cookbooks.value.reduce((acc, cookbook) => {
const householdName = householdsById.value[cookbook.householdId]?.name || "";
if (!acc[householdName]) {
acc[householdName] = [];
}
acc[householdName].push(cookbook);
return acc;
}, {} as Record<string, ReadCookBook[]>);
Object.entries(cookbooksByHousehold).forEach(([householdName, cookbooks]) => {
if (cookbooks[0].householdId === currentUserHouseholdId.value) {
ownLinks.push(...cookbooks.map(cookbookAsLink));
} else {
links.push({
key: householdName,
icon: $globals.icons.book,
title: householdName,
children: cookbooks.map(cookbookAsLink),
restricted: false,
});
}
});
links.sort((a, b) => a.title.localeCompare(b.title));
if ($auth.user && cookbookPreferences.value.hideOtherHouseholds) {
return ownLinks;
} else {
return [...ownLinks, ...links];
}
});
const createLinks = computed<SideBarLink[]>(() => [
{ {
insertDivider: false, insertDivider: false,
icon: $globals.icons.link, icon: $globals.icons.link,
@ -165,7 +207,7 @@ export default defineComponent({
}, },
]); ]);
const bottomLinks = computed<SidebarLinks>(() => [ const bottomLinks = computed<SideBarLink[]>(() => [
{ {
icon: $globals.icons.cog, icon: $globals.icons.cog,
title: i18n.tc("general.settings"), title: i18n.tc("general.settings"),
@ -174,7 +216,7 @@ export default defineComponent({
}, },
]); ]);
const topLinks = computed<SidebarLinks>(() => [ const topLinks = computed<SideBarLink[]>(() => [
{ {
icon: $globals.icons.silverwareForkKnife, icon: $globals.icons.silverwareForkKnife,
to: `/g/${groupSlug.value}`, to: `/g/${groupSlug.value}`,

View file

@ -135,7 +135,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api"; import { computed, defineComponent, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { SidebarLinks } from "~/types/application-types"; import { SidebarLinks } from "~/types/application-types";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
@ -192,13 +192,29 @@ export default defineComponent({
const userProfileLink = computed(() => $auth.user ? "/user/profile" : undefined); const userProfileLink = computed(() => $auth.user ? "/user/profile" : undefined);
const state = reactive({ const state = reactive({
dropDowns: {}, dropDowns: {} as Record<string, boolean>,
topSelected: null as string[] | null, topSelected: null as string[] | null,
secondarySelected: null as string[] | null, secondarySelected: null as string[] | null,
bottomSelected: null as string[] | null, bottomSelected: null as string[] | null,
hasOpenedBefore: false as boolean, hasOpenedBefore: false as boolean,
}); });
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || []), ...(props.bottomLinks || [])]);
function initDropdowns() {
allLinks.value.forEach((link) => {
state.dropDowns[link.title] = link.childrenStartExpanded || false;
})
}
watch(
() => allLinks,
() => {
initDropdowns();
},
{
deep: true,
}
);
return { return {
...toRefs(state), ...toRefs(state),
userFavoritesLink, userFavoritesLink,

View file

@ -99,10 +99,10 @@ export const useCookbooks = function () {
loading.value = false; loading.value = false;
}, },
async createOne() { async createOne(name: string | null = null) {
loading.value = true; loading.value = true;
const { data } = await api.cookbooks.createOne({ const { data } = await api.cookbooks.createOne({
name: i18n.t("cookbook.household-cookbook-name", [household.value?.name || "", String((cookbookStore?.value?.length ?? 0) + 1)]) as string, name: name || i18n.t("cookbook.household-cookbook-name", [household.value?.name || "", String((cookbookStore?.value?.length ?? 0) + 1)]) as string,
position: (cookbookStore?.value?.length ?? 0) + 1, position: (cookbookStore?.value?.length ?? 0) + 1,
queryFilterString: "", queryFilterString: "",
}); });
@ -129,18 +129,18 @@ export const useCookbooks = function () {
return data; return data;
}, },
async updateOrder() { async updateOrder(cookbooks: ReadCookBook[]) {
if (!cookbookStore?.value) { if (!cookbooks?.length) {
return; return;
} }
loading.value = true; loading.value = true;
cookbookStore.value.forEach((element, index) => { cookbooks.forEach((element, index) => {
element.position = index + 1; element.position = index + 1;
}); });
const { data } = await api.cookbooks.updateAll(cookbookStore.value); const { data } = await api.cookbooks.updateAll(cookbooks);
if (data && cookbookStore?.value) { if (data && cookbookStore?.value) {
this.refreshAll(); this.refreshAll();

View file

@ -45,6 +45,10 @@ export interface UserParsingPreferences {
parser: RegisteredParser; parser: RegisteredParser;
} }
export interface UserCookbooksPreferences {
hideOtherHouseholds: boolean;
}
export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> { export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> {
const fromStorage = useLocalStorage( const fromStorage = useLocalStorage(
"meal-planner-preferences", "meal-planner-preferences",
@ -153,3 +157,17 @@ export function useParsingPreferences(): Ref<UserParsingPreferences> {
return fromStorage; return fromStorage;
} }
export function useCookbookPreferences(): Ref<UserCookbooksPreferences> {
const fromStorage = useLocalStorage(
"cookbook-preferences",
{
hideOtherHouseholds: false,
},
{ mergeDefaults: true }
// we cast to a Ref because by default it will return an optional type ref
// but since we pass defaults we know all properties are set.
) as unknown as Ref<UserCookbooksPreferences>;
return fromStorage;
}

View file

@ -1327,6 +1327,8 @@
"cookbook": { "cookbook": {
"cookbooks": "Cookbooks", "cookbooks": "Cookbooks",
"description": "Cookbooks are another way to organize recipes by creating cross sections of recipes, organizers, and other filters. Creating a cookbook will add an entry to the side-bar and all the recipes with the filters chosen will be displayed in the cookbook.", "description": "Cookbooks are another way to organize recipes by creating cross sections of recipes, organizers, and other filters. Creating a cookbook will add an entry to the side-bar and all the recipes with the filters chosen will be displayed in the cookbook.",
"hide-cookbooks-from-other-households": "Hide Cookbooks from Other Households",
"hide-cookbooks-from-other-households-description": "When enabled, only cookbooks from your household will appear on the sidebar",
"public-cookbook": "Public Cookbook", "public-cookbook": "Public Cookbook",
"public-cookbook-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.", "public-cookbook-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.",
"filter-options": "Filter Options", "filter-options": "Filter Options",

View file

@ -48,20 +48,33 @@
{{ $t('cookbook.description') }} {{ $t('cookbook.description') }}
</BasePageTitle> </BasePageTitle>
<div class="my-6">
<v-checkbox
v-model="cookbookPreferences.hideOtherHouseholds"
:label="$tc('cookbook.hide-cookbooks-from-other-households')"
hide-details
/>
<div class="ml-8">
<p class="text-subtitle-2 my-0 py-0">
{{ $tc("cookbook.hide-cookbooks-from-other-households-description") }}
</p>
</div>
</div>
<!-- Create New --> <!-- Create New -->
<BaseButton create @click="createCookbook" /> <BaseButton create @click="createCookbook" />
<!-- Cookbook List --> <!-- Cookbook List -->
<v-expansion-panels class="mt-2"> <v-expansion-panels class="mt-2">
<draggable <draggable
v-model="cookbooks" v-model="myCookbooks"
handle=".handle" handle=".handle"
delay="250" delay="250"
:delay-on-touch-only="true" :delay-on-touch-only="true"
style="width: 100%" style="width: 100%"
@change="actions.updateOrder()" @change="actions.updateOrder(myCookbooks)"
> >
<v-expansion-panel v-for="cookbook in cookbooks" :key="cookbook.id" class="my-2 left-border rounded"> <v-expansion-panel v-for="cookbook in myCookbooks" :key="cookbook.id" class="my-2 left-border rounded">
<v-expansion-panel-header disable-icon-rotate class="headline"> <v-expansion-panel-header disable-icon-rotate class="headline">
<div class="d-flex align-center"> <div class="d-flex align-center">
<v-icon large left> <v-icon large left>
@ -110,11 +123,13 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, onBeforeUnmount, onMounted, reactive, ref } from "@nuxtjs/composition-api"; import { computed, defineComponent, onBeforeUnmount, onMounted, reactive, ref, useContext } from "@nuxtjs/composition-api";
import draggable from "vuedraggable"; import draggable from "vuedraggable";
import { useCookbooks } from "@/composables/use-group-cookbooks"; import { useCookbooks } from "@/composables/use-group-cookbooks";
import { useHouseholdSelf } from "@/composables/use-households";
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue"; import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
import { ReadCookBook } from "~/lib/api/types/cookbook"; import { ReadCookBook } from "~/lib/api/types/cookbook";
import { useCookbookPreferences } from "~/composables/use-users/preferences";
export default defineComponent({ export default defineComponent({
components: { CookbookEditor, draggable }, components: { CookbookEditor, draggable },
@ -124,13 +139,28 @@ export default defineComponent({
create: false, create: false,
delete: false, delete: false,
}); });
const { cookbooks, actions } = useCookbooks();
const { $auth, i18n } = useContext();
const { cookbooks: allCookbooks, actions } = useCookbooks();
const myCookbooks = computed<ReadCookBook[]>({
get: () => {
return allCookbooks.value?.filter((cookbook) => {
return cookbook.householdId === $auth.user?.householdId;
}) || [];
},
set: (value: ReadCookBook[]) => {
actions.updateOrder(value);
},
});
const { household } = useHouseholdSelf();
const cookbookPreferences = useCookbookPreferences()
// create // create
const createTargetKey = ref(0); const createTargetKey = ref(0);
const createTarget = ref<ReadCookBook | null>(null); const createTarget = ref<ReadCookBook | null>(null);
async function createCookbook() { async function createCookbook() {
await actions.createOne().then((cookbook) => { const name = i18n.t("cookbook.household-cookbook-name", [household.value?.name || "", String((myCookbooks.value?.length ?? 0) + 1)]) as string
await actions.createOne(name).then((cookbook) => {
createTarget.value = cookbook as ReadCookBook; createTarget.value = cookbook as ReadCookBook;
createTargetKey.value++; createTargetKey.value++;
}); });
@ -177,7 +207,8 @@ export default defineComponent({
}); });
return { return {
cookbooks, myCookbooks,
cookbookPreferences,
actions, actions,
dialogStates, dialogStates,
// create // create

View file

@ -5,6 +5,7 @@ export interface SideBarLink {
href?: string; href?: string;
title: string; title: string;
children?: SideBarLink[]; children?: SideBarLink[];
childrenStartExpanded?: boolean;
restricted: boolean; restricted: boolean;
} }

View file

@ -2,6 +2,7 @@ from collections.abc import Callable
from logging import Logger from logging import Logger
from typing import Generic, TypeVar from typing import Generic, TypeVar
import sqlalchemy.exc
from fastapi import HTTPException, status from fastapi import HTTPException, status
from pydantic import UUID4, BaseModel from pydantic import UUID4, BaseModel
@ -57,10 +58,16 @@ class HttpRepo(Generic[C, R, U]):
# Respond # Respond
msg = self.get_exception_message(ex) msg = self.get_exception_message(ex)
raise HTTPException( if isinstance(ex, sqlalchemy.exc.NoResultFound):
status.HTTP_400_BAD_REQUEST, raise HTTPException(
detail=ErrorResponse.respond(message=msg, exception=str(ex)), status.HTTP_404_NOT_FOUND,
) detail=ErrorResponse.respond(message=msg, exception=str(ex)),
)
else:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse.respond(message=msg, exception=str(ex)),
)
def create_one(self, data: C) -> R | None: def create_one(self, data: C) -> R | None:
item: R | None = None item: R | None = None

View file

@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4 from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions from mealie.core.exceptions import mealie_registered_exceptions
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import BaseCrudController, controller from mealie.routes._base import BaseCrudController, controller
from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute from mealie.routes._base.routers import MealieCrudRoute
@ -26,9 +27,13 @@ router = APIRouter(prefix="/households/cookbooks", tags=["Households: Cookbooks"
@controller(router) @controller(router)
class GroupCookbookController(BaseCrudController): class GroupCookbookController(BaseCrudController):
@cached_property @cached_property
def repo(self): def cookbooks(self):
return self.repos.cookbooks return self.repos.cookbooks
@cached_property
def group_cookbooks(self):
return get_repositories(self.session, group_id=self.group_id, household_id=None).cookbooks
def registered_exceptions(self, ex: type[Exception]) -> str: def registered_exceptions(self, ex: type[Exception]) -> str:
registered = { registered = {
**mealie_registered_exceptions(self.translator), **mealie_registered_exceptions(self.translator),
@ -38,14 +43,15 @@ class GroupCookbookController(BaseCrudController):
@cached_property @cached_property
def mixins(self): def mixins(self):
return HttpRepo[CreateCookBook, ReadCookBook, UpdateCookBook]( return HttpRepo[CreateCookBook, ReadCookBook, UpdateCookBook](
self.repo, self.cookbooks,
self.logger, self.logger,
self.registered_exceptions, self.registered_exceptions,
) )
@router.get("", response_model=CookBookPagination) @router.get("", response_model=CookBookPagination)
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
response = self.repo.page_all( # Fetch all cookbooks for the group, rather than the household
response = self.group_cookbooks.page_all(
pagination=q, pagination=q,
override=ReadCookBook, override=ReadCookBook,
) )
@ -106,7 +112,8 @@ class GroupCookbookController(BaseCrudController):
except ValueError: except ValueError:
match_attr = "slug" match_attr = "slug"
cookbook = self.repo.get_one(item_id, match_attr) # Allow fetching other households' cookbooks
cookbook = self.group_cookbooks.get_one(item_id, match_attr)
if cookbook is None: if cookbook is None:
raise HTTPException(status_code=404) raise HTTPException(status_code=404)

View file

@ -105,8 +105,8 @@ class BaseRecipeController(BaseCrudController):
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
@cached_property @cached_property
def cookbooks_repo(self) -> RepositoryGeneric[ReadCookBook, CookBook]: def group_cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
return self.repos.cookbooks return get_repositories(self.session, group_id=self.group_id, household_id=None).cookbooks
@cached_property @cached_property
def service(self) -> RecipeService: def service(self) -> RecipeService:
@ -354,7 +354,7 @@ class RecipeController(BaseRecipeController):
cb_match_attr = "id" cb_match_attr = "id"
except ValueError: except ValueError:
cb_match_attr = "slug" cb_match_attr = "slug"
cookbook_data = self.cookbooks_repo.get_one(search_query.cookbook, cb_match_attr) cookbook_data = self.group_cookbooks.get_one(search_query.cookbook, cb_match_attr)
if cookbook_data is None: if cookbook_data is None:
raise HTTPException(status_code=404, detail="cookbook not found") raise HTTPException(status_code=404, detail="cookbook not found")

View file

@ -60,6 +60,8 @@ def test_create_cookbook(api_client: TestClient, unique_user: TestUser):
page_data = get_page_data(unique_user.group_id, unique_user.household_id) page_data = get_page_data(unique_user.group_id, unique_user.household_id)
response = api_client.post(api_routes.households_cookbooks, json=page_data, headers=unique_user.token) response = api_client.post(api_routes.households_cookbooks, json=page_data, headers=unique_user.token)
assert response.status_code == 201 assert response.status_code == 201
assert response.json()["groupId"] == unique_user.group_id
assert response.json()["householdId"] == unique_user.household_id
@pytest.mark.parametrize("name_input", ["", " ", "@"]) @pytest.mark.parametrize("name_input", ["", " ", "@"])
@ -78,9 +80,22 @@ def test_create_cookbook_bad_name(api_client: TestClient, unique_user: TestUser,
assert response.status_code == 422 assert response.status_code == 422
def test_read_cookbook(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]): @pytest.mark.parametrize("use_other_household", [True, False])
def test_read_cookbook(
api_client: TestClient,
unique_user: TestUser,
h2_user: TestUser,
cookbooks: list[TestCookbook],
use_other_household: bool,
):
sample = random.choice(cookbooks) sample = random.choice(cookbooks)
response = api_client.get(api_routes.households_cookbooks_item_id(sample.id), headers=unique_user.token) if use_other_household:
headers = h2_user.token
else:
headers = unique_user.token
# all households should be able to fetch all cookbooks
response = api_client.get(api_routes.households_cookbooks_item_id(sample.id), headers=headers)
assert response.status_code == 200 assert response.status_code == 200
page_data = response.json() page_data = response.json()
@ -111,6 +126,28 @@ def test_update_cookbook(api_client: TestClient, unique_user: TestUser, cookbook
assert page_data["slug"] == update_data["name"] assert page_data["slug"] == update_data["name"]
def test_update_cookbook_other_household(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, cookbooks: list[TestCookbook]
):
cookbook = random.choice(cookbooks)
update_data = get_page_data(unique_user.group_id, unique_user.household_id)
update_data["name"] = random_string(10)
response = api_client.put(
api_routes.households_cookbooks_item_id(cookbook.id), json=update_data, headers=h2_user.token
)
assert response.status_code == 404
response = api_client.get(api_routes.households_cookbooks_item_id(cookbook.id), headers=unique_user.token)
assert response.status_code == 200
page_data = response.json()
assert page_data["name"] != update_data["name"]
assert page_data["slug"] != update_data["name"]
def test_update_cookbooks_many(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]): def test_update_cookbooks_many(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]):
pages = [x.data for x in cookbooks] pages = [x.data for x in cookbooks]
@ -135,6 +172,20 @@ def test_update_cookbooks_many(api_client: TestClient, unique_user: TestUser, co
assert str(know) in server_ids assert str(know) in server_ids
def test_update_cookbooks_many_other_household(
api_client: TestClient, unique_user: TestUser, h2_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(api_routes.households_cookbooks, json=utils.jsonify(reverse_order), headers=h2_user.token)
assert response.status_code == 404
def test_delete_cookbook(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]): def test_delete_cookbook(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]):
sample = random.choice(cookbooks) sample = random.choice(cookbooks)
response = api_client.delete(api_routes.households_cookbooks_item_id(sample.id), headers=unique_user.token) response = api_client.delete(api_routes.households_cookbooks_item_id(sample.id), headers=unique_user.token)
@ -145,6 +196,18 @@ def test_delete_cookbook(api_client: TestClient, unique_user: TestUser, cookbook
assert response.status_code == 404 assert response.status_code == 404
def test_delete_cookbook_other_household(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, cookbooks: list[TestCookbook]
):
sample = random.choice(cookbooks)
response = api_client.delete(api_routes.households_cookbooks_item_id(sample.id), headers=h2_user.token)
assert response.status_code == 404
response = api_client.get(api_routes.households_cookbooks_item_id(sample.slug), headers=unique_user.token)
assert response.status_code == 200
@pytest.mark.parametrize( @pytest.mark.parametrize(
"qf_string, expected_code", "qf_string, expected_code",
[ [

View file

@ -299,3 +299,16 @@ def test_cookbook_recipes_includes_all_households(api_client: TestClient, unique
assert recipe.id in fetched_recipe_ids assert recipe.id in fetched_recipe_ids
for recipe in other_recipes: for recipe in other_recipes:
assert recipe.id in fetched_recipe_ids assert recipe.id in fetched_recipe_ids
def test_cookbooks_from_other_households(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
h2_cookbook = h2_user.repos.cookbooks.create(
SaveCookBook(
name=random_string(),
group_id=h2_user.group_id,
household_id=h2_user.household_id,
)
)
response = api_client.get(api_routes.recipes, params={"cookbook": h2_cookbook.slug}, headers=unique_user.token)
assert response.status_code == 200