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:
parent
8983745106
commit
87f4b23711
13 changed files with 264 additions and 55 deletions
|
@ -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,
|
||||||
|
|
|
@ -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 cookbooks.value.map((cookbook) => {
|
|
||||||
return {
|
return {
|
||||||
key: cookbook.slug,
|
key: cookbook.slug || "",
|
||||||
icon: $globals.icons.pages,
|
icon: $globals.icons.pages,
|
||||||
title: cookbook.name,
|
title: cookbook.name,
|
||||||
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`,
|
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug || ""}`,
|
||||||
|
restricted: false,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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}`,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,6 +58,12 @@ class HttpRepo(Generic[C, R, U]):
|
||||||
# Respond
|
# Respond
|
||||||
msg = self.get_exception_message(ex)
|
msg = self.get_exception_message(ex)
|
||||||
|
|
||||||
|
if isinstance(ex, sqlalchemy.exc.NoResultFound):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=ErrorResponse.respond(message=msg, exception=str(ex)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_400_BAD_REQUEST,
|
status.HTTP_400_BAD_REQUEST,
|
||||||
detail=ErrorResponse.respond(message=msg, exception=str(ex)),
|
detail=ErrorResponse.respond(message=msg, exception=str(ex)),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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",
|
||||||
[
|
[
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue