feat: Filter Recipes By Household (and a ton of bug fixes) (#4207)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-09-22 09:59:20 -05:00 committed by GitHub
parent 2a6922a85c
commit 7c274de778
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 896 additions and 590 deletions

View File

@ -69,50 +69,52 @@
@toggle-dense-view="toggleMobileCards()"
/>
</v-app-bar>
<div v-if="recipes" class="mt-2">
<v-row v-if="!useMobileCards">
<v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3">
<v-lazy>
<RecipeCard
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
/>
</v-lazy>
</v-col>
</v-row>
<v-row v-else dense>
<v-col
v-for="recipe in recipes"
:key="recipe.name"
cols="12"
:sm="singleColumn ? '12' : '12'"
:md="singleColumn ? '12' : '6'"
:lg="singleColumn ? '12' : '4'"
:xl="singleColumn ? '12' : '3'"
>
<v-lazy>
<RecipeCardMobile
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
/>
</v-lazy>
</v-col>
</v-row>
<div v-if="recipes && ready">
<div class="mt-2">
<v-row v-if="!useMobileCards">
<v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3">
<v-lazy>
<RecipeCard
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
/>
</v-lazy>
</v-col>
</v-row>
<v-row v-else dense>
<v-col
v-for="recipe in recipes"
:key="recipe.name"
cols="12"
:sm="singleColumn ? '12' : '12'"
:md="singleColumn ? '12' : '6'"
:lg="singleColumn ? '12' : '4'"
:xl="singleColumn ? '12' : '3'"
>
<v-lazy>
<RecipeCardMobile
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
/>
</v-lazy>
</v-col>
</v-row>
</div>
<v-card v-intersect="infiniteScroll"></v-card>
<v-fade-transition>
<AppLoader v-if="loading" :loading="loading" />
</v-fade-transition>
</div>
<v-card v-intersect="infiniteScroll"></v-card>
<v-fade-transition>
<AppLoader v-if="loading" :loading="loading" />
</v-fade-transition>
</div>
</template>
@ -223,36 +225,42 @@ export default defineComponent({
const queryFilter = computed(() => {
const orderBy = props.query?.orderBy || preferences.value.orderBy;
return preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
if (props.query.queryFilter && orderByFilter) {
return `(${props.query.queryFilter}) AND ${orderByFilter}`;
} else if (props.query.queryFilter) {
return props.query.queryFilter;
} else {
return orderByFilter;
}
});
async function fetchRecipes(pageCount = 1) {
return await fetchMore(
page.value,
// we double-up the first call to avoid a bug with large screens that render the entire first page without scrolling, preventing additional loading
perPage * pageCount,
props.query?.orderBy || preferences.value.orderBy,
props.query?.orderDirection || preferences.value.orderDirection,
props.query,
// filter out recipes that have a null value for the property we're sorting by
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
queryFilter.value
);
}
onMounted(async () => {
if (props.query) {
await initRecipes();
ready.value = true;
}
await initRecipes();
ready.value = true;
});
let lastQuery: string | undefined;
let lastQuery: string | undefined = JSON.stringify(props.query);
watch(
() => props.query,
async (newValue: RecipeSearchQuery | undefined) => {
const newValueString = JSON.stringify(newValue)
if (newValue && (!ready.value || lastQuery !== newValueString)) {
if (lastQuery !== newValueString) {
lastQuery = newValueString;
ready.value = false;
await initRecipes();
ready.value = true;
}
@ -261,8 +269,12 @@ export default defineComponent({
async function initRecipes() {
page.value = 1;
const newRecipes = await fetchRecipes(2);
if (!newRecipes.length) {
hasMore.value = true;
// we double-up the first call to avoid a bug with large screens that render
// the entire first page without scrolling, preventing additional loading
const newRecipes = await fetchRecipes(page.value + 1);
if (newRecipes.length < perPage) {
hasMore.value = false;
}
@ -274,7 +286,7 @@ export default defineComponent({
const infiniteScroll = useThrottleFn(() => {
useAsync(async () => {
if (!ready.value || !hasMore.value || loading.value) {
if (!hasMore.value || loading.value) {
return;
}
@ -282,9 +294,10 @@ export default defineComponent({
page.value = page.value + 1;
const newRecipes = await fetchRecipes();
if (!newRecipes.length) {
if (newRecipes.length < perPage) {
hasMore.value = false;
} else {
}
if (newRecipes.length) {
context.emit(APPEND_RECIPES_EVENT, newRecipes);
}
@ -379,6 +392,7 @@ export default defineComponent({
displayTitleIcon,
EVENTS,
infiniteScroll,
ready,
loading,
navigateRandom,
preferences,

View File

@ -3,6 +3,8 @@
v-model="selected"
item-key="id"
show-select
sort-by="dateAdded"
sort-desc
:headers="headers"
:items="recipes"
:items-per-page="15"
@ -39,6 +41,9 @@
</v-list-item-content>
</v-list-item>
</template>
<template #item.dateAdded="{ item }">
{{ formatDate(item.dateAdded) }}
</template>
</v-data-table>
</template>
@ -132,6 +137,14 @@ export default defineComponent({
return hdrs;
});
function formatDate(date: string) {
try {
return i18n.d(Date.parse(date), "medium");
} catch {
return "";
}
}
// ============
// Group Members
const api = useUserApi();
@ -160,6 +173,7 @@ export default defineComponent({
groupSlug,
setValue,
headers,
formatDate,
members,
getMember,
};

View File

@ -53,6 +53,14 @@
{{ $t("general.foods") }}
</SearchFilter>
<!-- Household Filter -->
<SearchFilter v-if="households.length > 1" v-model="selectedHouseholds" :items="households" radio>
<v-icon left>
{{ $globals.icons.household }}
</v-icon>
{{ $t("household.households") }}
</SearchFilter>
<!-- Sort Options -->
<v-menu offset-y nudge-bottom="3">
<template #activator="{ on, attrs }">
@ -142,17 +150,25 @@ import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref,
import { watchDebounced } from "@vueuse/shared";
import SearchFilter from "~/components/Domain/SearchFilter.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useCategoryStore, useFoodStore, useTagStore, useToolStore } from "~/composables/store";
import {
useCategoryStore,
usePublicCategoryStore,
useFoodStore,
usePublicFoodStore,
useHouseholdStore,
usePublicHouseholdStore,
useTagStore,
usePublicTagStore,
useToolStore,
usePublicToolStore,
} from "~/composables/store";
import { useUserSearchQuerySession } from "~/composables/use-users/preferences";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useLazyRecipes } from "~/composables/recipes";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
import { usePublicCategoryStore } from "~/composables/store/use-category-store";
import { usePublicFoodStore } from "~/composables/store/use-food-store";
import { usePublicTagStore } from "~/composables/store/use-tag-store";
import { usePublicToolStore } from "~/composables/store/use-tool-store";
import { HouseholdSummary } from "~/lib/api/types/household";
export default defineComponent({
components: { SearchFilter, RecipeCardSection },
@ -186,6 +202,9 @@ export default defineComponent({
const foods = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
const selectedFoods = ref<IngredientFood[]>([]);
const households = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
const selectedHouseholds = ref([] as NoUndefinedField<HouseholdSummary>[]);
const tags = isOwnGroup.value ? useTagStore() : usePublicTagStore(groupSlug.value);
const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
@ -199,6 +218,7 @@ export default defineComponent({
search: state.value.search ? state.value.search : "",
categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.value),
households: toIDArray(selectedHouseholds.value),
tags: toIDArray(selectedTags.value),
tools: toIDArray(selectedTools.value),
requireAllCategories: state.value.requireAllCategories,
@ -239,10 +259,9 @@ export default defineComponent({
state.value.requireAllFoods = queryDefaults.requireAllFoods;
selectedCategories.value = [];
selectedFoods.value = [];
selectedHouseholds.value = [];
selectedTags.value = [];
selectedTools.value = [];
search();
}
function toggleOrderDirection() {
@ -280,6 +299,7 @@ export default defineComponent({
search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search,
orderBy: passedQuery.value.orderBy === queryDefaults.orderBy ? undefined : passedQuery.value.orderBy,
orderDirection: passedQuery.value.orderDirection === queryDefaults.orderDirection ? undefined : passedQuery.value.orderDirection,
households: !passedQuery.value.households?.length || passedQuery.value.households?.length === households.store.value.length ? undefined : passedQuery.value.households,
requireAllCategories: passedQuery.value.requireAllCategories ? "true" : undefined,
requireAllTags: passedQuery.value.requireAllTags ? "true" : undefined,
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
@ -361,13 +381,10 @@ export default defineComponent({
watch(
() => route.value.query,
() => {
if (state.value.ready) {
hydrateSearch();
if (!Object.keys(route.value.query).length) {
reset();
}
},
{
deep: true,
},
}
)
async function hydrateSearch() {
@ -423,9 +440,9 @@ export default defineComponent({
if (query.categories?.length) {
promises.push(
waitUntilAndExecute(
() => categories.items.value.length > 0,
() => categories.store.value.length > 0,
() => {
const result = categories.items.value.filter((item) =>
const result = categories.store.value.filter((item) =>
(query.categories as string[]).includes(item.id as string)
);
@ -440,9 +457,9 @@ export default defineComponent({
if (query.tags?.length) {
promises.push(
waitUntilAndExecute(
() => tags.items.value.length > 0,
() => tags.store.value.length > 0,
() => {
const result = tags.items.value.filter((item) => (query.tags as string[]).includes(item.id as string));
const result = tags.store.value.filter((item) => (query.tags as string[]).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
}
)
@ -454,9 +471,9 @@ export default defineComponent({
if (query.tools?.length) {
promises.push(
waitUntilAndExecute(
() => tools.items.value.length > 0,
() => tools.store.value.length > 0,
() => {
const result = tools.items.value.filter((item) => (query.tools as string[]).includes(item.id));
const result = tools.store.value.filter((item) => (query.tools as string[]).includes(item.id));
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
}
)
@ -469,13 +486,13 @@ export default defineComponent({
promises.push(
waitUntilAndExecute(
() => {
if (foods.foods.value) {
return foods.foods.value.length > 0;
if (foods.store.value) {
return foods.store.value.length > 0;
}
return false;
},
() => {
const result = foods.foods.value?.filter((item) => (query.foods as string[]).includes(item.id));
const result = foods.store.value?.filter((item) => (query.foods as string[]).includes(item.id));
selectedFoods.value = result ?? [];
}
)
@ -484,6 +501,25 @@ export default defineComponent({
selectedFoods.value = [];
}
if (query.households?.length) {
promises.push(
waitUntilAndExecute(
() => {
if (households.store.value) {
return households.store.value.length > 0;
}
return false;
},
() => {
const result = households.store.value?.filter((item) => (query.households as string[]).includes(item.id));
selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? [];
}
)
);
} else {
selectedHouseholds.value = [];
}
await Promise.allSettled(promises);
};
@ -515,6 +551,7 @@ export default defineComponent({
() => state.value.orderDirection,
selectedCategories,
selectedFoods,
selectedHouseholds,
selectedTags,
selectedTools,
],
@ -533,10 +570,11 @@ export default defineComponent({
search,
reset,
state,
categories: categories.items as unknown as NoUndefinedField<RecipeCategory>[],
tags: tags.items as unknown as NoUndefinedField<RecipeTag>[],
foods: foods.foods,
tools: tools.items as unknown as NoUndefinedField<RecipeTool>[],
categories: categories.store as unknown as NoUndefinedField<RecipeCategory>[],
tags: tags.store as unknown as NoUndefinedField<RecipeTag>[],
foods: foods.store,
tools: tools.store as unknown as NoUndefinedField<RecipeTool>[],
households: households.store as unknown as NoUndefinedField<HouseholdSummary>[],
sortable,
toggleOrderDirection,
@ -545,6 +583,7 @@ export default defineComponent({
selectedCategories,
selectedFoods,
selectedHouseholds,
selectedTags,
selectedTools,
appendRecipes,

View File

@ -289,11 +289,11 @@ export default defineComponent({
createAssignFood,
unitAutocomplete,
createAssignUnit,
foods: foodStore.foods,
foods: foodStore.store,
foodSearch,
toggleTitle,
unitActions: unitStore.actions,
units: unitStore.units,
units: unitStore.store,
unitSearch,
validators,
workingUnitData: unitsData.data,

View File

@ -135,7 +135,7 @@ export default defineComponent({
await store.actions.createOne({ ...state });
}
const newItem = store.items.value.find((item) => item.name === state.name);
const newItem = store.store.value.find((item) => item.name === state.name);
context.emit(CREATED_ITEM_EVENT, newItem);
dialog.value = false;

View File

@ -127,9 +127,9 @@ export default defineComponent({
const items = computed(() => {
if (!props.returnObject) {
return store.items.value.map((item) => item.name);
return store.store.value.map((item) => item.name);
}
return store.items.value;
return store.store.value;
});
function removeByIndex(index: number) {

View File

@ -105,7 +105,7 @@ export default defineComponent({
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const userApi = useUserApi();
userApi.groups.fetchHousehold(props.recipe.householdId).then(({ data }) => {
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined;
});
}

View File

@ -11,28 +11,43 @@
<v-card width="400">
<v-card-text>
<v-text-field v-model="state.search" class="mb-2" hide-details dense :label="$tc('search.search')" clearable />
<v-switch
v-if="requireAll != undefined"
v-model="requireAllValue"
dense
small
:label="`${requireAll ? $tc('search.has-all') : $tc('search.has-any')}`"
>
</v-switch>
<div class="d-flex py-4">
<v-switch
v-if="requireAll != undefined"
v-model="requireAllValue"
dense
small
hide-details
class="my-auto"
:label="`${requireAll ? $tc('search.has-all') : $tc('search.has-any')}`"
/>
<v-spacer />
<v-btn
small
color="accent"
class="mr-2 my-auto"
@click="clearSelection"
>
{{ $tc("search.clear-selection") }}
</v-btn>
</div>
<v-card v-if="filtered.length > 0" flat outlined>
<v-radio-group v-model="selectedRadio" class="ma-0 pa-0">
<v-virtual-scroll :items="filtered" height="300" item-height="51">
<template #default="{ item }">
<v-list-item :key="item.id" dense :value="item">
<v-list-item-action>
<v-checkbox v-model="selected" :value="item"></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title> {{ item.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item :key="item.id" dense :value="item">
<v-list-item-action>
<v-radio v-if="radio" :value="item" @click="handleRadioClick(item)" />
<v-checkbox v-else v-model="selected" :value="item" />
</v-list-item-action>
<v-list-item-content>
<v-list-item-title> {{ item.name }} </v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
</template>
</v-virtual-scroll>
</v-radio-group>
</v-card>
<div v-else>
<v-alert type="info" text> {{ $tc('search.no-results') }} </v-alert>
@ -65,6 +80,10 @@ export default defineComponent({
type: Boolean,
default: undefined,
},
radio: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const state = reactive({
@ -86,6 +105,13 @@ export default defineComponent({
},
});
const selectedRadio = computed({
get: () => (selected.value.length > 0 ? selected.value[0] : null),
set: (value) => {
context.emit("input", value ? [value] : []);
},
});
const filtered = computed(() => {
if (!state.search) {
return props.items;
@ -94,11 +120,26 @@ export default defineComponent({
return props.items.filter((item) => item.name.toLowerCase().includes(state.search.toLowerCase()));
});
const handleRadioClick = (item: SelectableItem) => {
if (selectedRadio.value === item) {
selectedRadio.value = null;
}
};
function clearSelection() {
selected.value = [];
selectedRadio.value = null;
state.search = "";
}
return {
requireAllValue,
state,
selected,
selectedRadio,
filtered,
handleRadioClick,
clearSelection,
};
},
});

View File

@ -44,6 +44,8 @@
item-key="id"
:show-select="bulkActions.length > 0"
:headers="activeHeaders"
:sort-by="initialSort"
:sort-desc="initialSortDesc"
:items="data || []"
:items-per-page="15"
:search="search"
@ -126,6 +128,14 @@ export default defineComponent({
type: Array as () => BulkAction[],
default: () => [],
},
initialSort: {
type: String,
default: "id",
},
initialSortDesc: {
type: Boolean,
default: false,
},
},
setup(props, context) {
// ===========================================================

View File

@ -1,3 +1,3 @@
export { useAppInfo } from "./use-app-info";
export { useStaticRoutes } from "./static-routes";
export { useAdminApi, useUserApi } from "./api-client";
export { useAdminApi, usePublicApi, usePublicExploreApi, useUserApi } from "./api-client";

View File

@ -0,0 +1,3 @@
export type BoundT = {
id?: string | number | null;
};

View File

@ -1,18 +1,15 @@
import { Ref, useAsync } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { BoundT } from "./types";
import { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
import { QueryValue } from "~/lib/api/base/route";
type BoundT = {
id?: string | number | null;
};
interface PublicStoreActions<T extends BoundT> {
interface ReadOnlyStoreActions<T extends BoundT> {
getAll(page?: number, perPage?: number, params?: any): Ref<T[] | null>;
refresh(): Promise<void>;
}
interface StoreActions<T extends BoundT> extends PublicStoreActions<T> {
interface StoreActions<T extends BoundT> extends ReadOnlyStoreActions<T> {
createOne(createData: T): Promise<T | null>;
updateOne(updateData: T): Promise<T | null>;
deleteOne(id: string | number): Promise<T | null>;
@ -20,16 +17,16 @@ interface StoreActions<T extends BoundT> extends PublicStoreActions<T> {
/**
* usePublicStoreActions is a factory function that returns a set of methods
* useReadOnlyActions is a factory function that returns a set of methods
* that can be reused to manage the state of a data store without using
* Vuex. This is primarily used for basic GET/GETALL operations that required
* a lot of refreshing hooks to be called on operations
*/
export function usePublicStoreActions<T extends BoundT>(
export function useReadOnlyActions<T extends BoundT>(
api: BaseCRUDAPIReadOnly<T>,
allRef: Ref<T[] | null> | null,
loading: Ref<boolean>
): PublicStoreActions<T> {
): ReadOnlyStoreActions<T> {
function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
params.orderBy ??= "name";
params.orderDirection ??= "asc";

View File

@ -0,0 +1,53 @@
import { ref, reactive, Ref } from "@nuxtjs/composition-api";
import { useReadOnlyActions, useStoreActions } from "./use-actions-factory";
import { BoundT } from "./types";
import { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
export const useData = function<T extends BoundT>(defaultObject: T) {
const data = reactive({ ...defaultObject });
function reset() {
Object.assign(data, defaultObject);
};
return { data, reset };
}
export const useReadOnlyStore = function<T extends BoundT>(
store: Ref<T[]>,
loading: Ref<boolean>,
api: BaseCRUDAPIReadOnly<T>,
) {
const actions = {
...useReadOnlyActions(api, store, loading),
flushStore() {
store.value = [];
},
};
if (!loading.value && (!store.value || store.value.length === 0)) {
const result = actions.getAll();
store.value = result.value || [];
}
return { store, actions };
}
export const useStore = function<T extends BoundT>(
store: Ref<T[]>,
loading: Ref<boolean>,
api: BaseCRUDAPI<unknown, T, unknown>,
) {
const actions = {
...useStoreActions(api, store, loading),
flushStore() {
store = ref([]);
},
};
if (!loading.value && (!store.value || store.value.length === 0)) {
const result = actions.getAll();
store.value = result.value || [];
}
return { store, actions };
}

View File

@ -32,6 +32,7 @@ export const useLazyRecipes = function (publicGroupSlug: string | null = null) {
searchSeed: query?._searchSeed, // unused, but pass it along for completeness of data
search: query?.search,
cookbook: query?.cookbook,
households: query?.households,
categories: query?.categories,
requireAllCategories: query?.requireAllCategories,
tags: query?.tags,

View File

@ -1,6 +1,7 @@
export { useFoodStore, useFoodData } from "./use-food-store";
export { useUnitStore, useUnitData } from "./use-unit-store";
export { useCategoryStore, usePublicCategoryStore, useCategoryData } from "./use-category-store";
export { useFoodStore, usePublicFoodStore, useFoodData } from "./use-food-store";
export { useHouseholdStore, usePublicHouseholdStore } from "./use-household-store";
export { useLabelStore, useLabelData } from "./use-label-store";
export { useToolStore, useToolData } from "./use-tool-store";
export { useCategoryStore, useCategoryData } from "./use-category-store";
export { useTagStore, useTagData } from "./use-tag-store";
export { useTagStore, usePublicTagStore, useTagData } from "./use-tag-store";
export { useToolStore, usePublicToolStore, useToolData } from "./use-tool-store";
export { useUnitStore, useUnitData } from "./use-unit-store";

View File

@ -1,73 +1,26 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
import { usePublicExploreApi } from "../api/api-client";
import { useUserApi } from "~/composables/api";
import { ref, Ref } from "@nuxtjs/composition-api";
import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory";
import { RecipeCategory } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
const categoryStore: Ref<RecipeCategory[]> = ref([]);
const publicStoreLoading = ref(false);
const storeLoading = ref(false);
const store: Ref<RecipeCategory[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function useCategoryData() {
const data = reactive({
export const useCategoryData = function () {
return useData<RecipeCategory>({
id: "",
name: "",
slug: undefined,
slug: "",
});
function reset() {
data.id = "";
data.name = "";
data.slug = undefined;
}
return {
data,
reset,
};
}
export function usePublicCategoryStore(groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
const loading = publicStoreLoading;
const actions = {
...usePublicStoreActions<RecipeCategory>(api.categories, categoryStore, loading),
flushStore() {
categoryStore.value = [];
},
};
if (!loading.value && (!categoryStore.value || categoryStore.value?.length === 0)) {
actions.getAll();
}
return {
items: categoryStore,
actions,
loading,
};
}
export function useCategoryStore() {
// passing the group slug switches to using the public API
export const useCategoryStore = function () {
const api = useUserApi();
const loading = storeLoading;
const actions = {
...useStoreActions<RecipeCategory>(api.categories, categoryStore, loading),
flushStore() {
categoryStore.value = [];
},
};
if (!loading.value && (!categoryStore.value || categoryStore.value?.length === 0)) {
actions.getAll();
}
return {
items: categoryStore,
actions,
loading,
};
return useStore<RecipeCategory>(store, loading, api.categories);
}
export const usePublicCategoryStore = function (groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
return useReadOnlyStore<RecipeCategory>(store, publicLoading, api.categories);
}

View File

@ -1,73 +1,28 @@
import { ref, reactive, Ref } from "@nuxtjs/composition-api";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
import { usePublicExploreApi } from "../api/api-client";
import { useUserApi } from "~/composables/api";
import { ref, Ref } from "@nuxtjs/composition-api";
import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory";
import { IngredientFood } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
let foodStore: Ref<IngredientFood[] | null> = ref([]);
const publicStoreLoading = ref(false);
const storeLoading = ref(false);
const store: Ref<IngredientFood[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
/**
* useFoodData returns a template reactive object
* for managing the creation of foods. It also provides a
* function to reset the data back to the initial state.
*/
export const useFoodData = function () {
const data: IngredientFood = reactive({
return useData<IngredientFood>({
id: "",
name: "",
description: "",
labelId: undefined,
onHand: false,
});
function reset() {
data.id = "";
data.name = "";
data.description = "";
data.labelId = undefined;
data.onHand = false;
}
return {
data,
reset,
};
};
export const usePublicFoodStore = function (groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
const loading = publicStoreLoading;
const actions = {
...usePublicStoreActions(api.foods, foodStore, loading),
flushStore() {
foodStore = ref([]);
},
};
if (!loading.value && (!foodStore.value || foodStore.value.length === 0)) {
foodStore = actions.getAll();
}
return { foods: foodStore, actions };
};
}
export const useFoodStore = function () {
const api = useUserApi();
const loading = storeLoading;
return useStore<IngredientFood>(store, loading, api.foods);
}
const actions = {
...useStoreActions(api.foods, foodStore, loading),
flushStore() {
foodStore.value = [];
},
};
if (!loading.value && (!foodStore.value || foodStore.value.length === 0)) {
foodStore = actions.getAll();
}
return { foods: foodStore, actions };
};
export const usePublicFoodStore = function (groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
return useReadOnlyStore<IngredientFood>(store, publicLoading, api.foods);
}

View File

@ -0,0 +1,18 @@
import { ref, Ref } from "@nuxtjs/composition-api";
import { useReadOnlyStore } from "../partials/use-store-factory";
import { HouseholdSummary } from "~/lib/api/types/household";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
const store: Ref<HouseholdSummary[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export const useHouseholdStore = function () {
const api = useUserApi();
return useReadOnlyStore<HouseholdSummary>(store, loading, api.households);
}
export const usePublicHouseholdStore = function (groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
return useReadOnlyStore<HouseholdSummary>(store, publicLoading, api.households);
}

View File

@ -1,50 +1,21 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { ref, Ref } from "@nuxtjs/composition-api";
import { useData, useStore } from "../partials/use-store-factory";
import { MultiPurposeLabelOut } from "~/lib/api/types/labels";
import { useUserApi } from "~/composables/api";
let labelStore: Ref<MultiPurposeLabelOut[] | null> = ref([]);
const storeLoading = ref(false);
const store: Ref<MultiPurposeLabelOut[]> = ref([]);
const loading = ref(false);
export function useLabelData() {
const data = reactive({
export const useLabelData = function () {
return useData<MultiPurposeLabelOut>({
groupId: "",
id: "",
name: "",
color: "",
});
function reset() {
data.groupId = "";
data.id = "";
data.name = "";
data.color = "";
}
return {
data,
reset,
};
}
export function useLabelStore() {
export const useLabelStore = function () {
const api = useUserApi();
const loading = storeLoading;
const actions = {
...useStoreActions<MultiPurposeLabelOut>(api.multiPurposeLabels, labelStore, loading),
flushStore() {
labelStore.value = [];
},
};
if (!loading.value && (!labelStore.value || labelStore.value?.length === 0)) {
labelStore = actions.getAll();
}
return {
labels: labelStore,
actions,
loading,
};
return useStore<MultiPurposeLabelOut>(store, loading, api.multiPurposeLabels);
}

View File

@ -1,72 +1,26 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
import { usePublicExploreApi } from "../api/api-client";
import { useUserApi } from "~/composables/api";
import { ref, Ref } from "@nuxtjs/composition-api";
import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory";
import { RecipeTag } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
const items: Ref<RecipeTag[]> = ref([]);
const publicStoreLoading = ref(false);
const storeLoading = ref(false);
const store: Ref<RecipeTag[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function useTagData() {
const data = reactive({
export const useTagData = function () {
return useData<RecipeTag>({
id: "",
name: "",
slug: undefined,
slug: "",
});
function reset() {
data.id = "";
data.name = "";
data.slug = undefined;
}
return {
data,
reset,
};
}
export function usePublicTagStore(groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
const loading = publicStoreLoading;
const actions = {
...usePublicStoreActions<RecipeTag>(api.tags, items, loading),
flushStore() {
items.value = [];
},
};
if (!loading.value && (!items.value || items.value?.length === 0)) {
actions.getAll();
}
return {
items,
actions,
loading,
};
}
export function useTagStore() {
export const useTagStore = function () {
const api = useUserApi();
const loading = storeLoading;
const actions = {
...useStoreActions<RecipeTag>(api.tags, items, loading),
flushStore() {
items.value = [];
},
};
if (!loading.value && (!items.value || items.value?.length === 0)) {
actions.getAll();
}
return {
items,
actions,
loading,
};
return useStore<RecipeTag>(store, loading, api.tags);
}
export const usePublicTagStore = function (groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
return useReadOnlyStore<RecipeTag>(store, publicLoading, api.tags);
}

View File

@ -1,74 +1,27 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { usePublicExploreApi } from "../api/api-client";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
import { useUserApi } from "~/composables/api";
import { ref, Ref } from "@nuxtjs/composition-api";
import { useData, useReadOnlyStore, useStore } from "../partials/use-store-factory";
import { RecipeTool } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
const toolStore: Ref<RecipeTool[]> = ref([]);
const publicStoreLoading = ref(false);
const storeLoading = ref(false);
const store: Ref<RecipeTool[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function useToolData() {
const data = reactive({
export const useToolData = function () {
return useData<RecipeTool>({
id: "",
name: "",
slug: undefined,
slug: "",
onHand: false,
});
function reset() {
data.id = "";
data.name = "";
data.slug = undefined;
data.onHand = false;
}
return {
data,
reset,
};
}
export function usePublicToolStore(groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
const loading = publicStoreLoading;
const actions = {
...usePublicStoreActions<RecipeTool>(api.tools, toolStore, loading),
flushStore() {
toolStore.value = [];
},
};
if (!loading.value && (!toolStore.value || toolStore.value?.length === 0)) {
actions.getAll();
}
return {
items: toolStore,
actions,
loading,
};
}
export function useToolStore() {
export const useToolStore = function () {
const api = useUserApi();
const loading = storeLoading;
const actions = {
...useStoreActions<RecipeTool>(api.tools, toolStore, loading),
flushStore() {
toolStore.value = [];
},
};
if (!loading.value && (!toolStore.value || toolStore.value?.length === 0)) {
actions.getAll();
}
return {
items: toolStore,
actions,
loading,
};
return useStore<RecipeTool>(store, loading, api.tools);
}
export const usePublicToolStore = function (groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
return useReadOnlyStore<RecipeTool>(store, publicLoading, api.tools);
}

View File

@ -1,53 +1,22 @@
import { ref, reactive, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { useUserApi } from "~/composables/api";
import { ref, Ref } from "@nuxtjs/composition-api";
import { useData, useStore } from "../partials/use-store-factory";
import { IngredientUnit } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api";
let unitStore: Ref<IngredientUnit[] | null> = ref([]);
const storeLoading = ref(false);
const store: Ref<IngredientUnit[]> = ref([]);
const loading = ref(false);
/**
* useUnitData returns a template reactive object
* for managing the creation of units. It also provides a
* function to reset the data back to the initial state.
*/
export const useUnitData = function () {
const data: IngredientUnit = reactive({
return useData<IngredientUnit>({
id: "",
name: "",
fraction: true,
abbreviation: "",
description: "",
});
function reset() {
data.id = "";
data.name = "";
data.fraction = true;
data.abbreviation = "";
data.description = "";
}
return {
data,
reset,
};
};
}
export const useUnitStore = function () {
const api = useUserApi();
const loading = storeLoading;
const actions = {
...useStoreActions<IngredientUnit>(api.units, unitStore, loading),
flushStore() {
unitStore.value = [];
},
};
if (!loading.value && (!unitStore.value || unitStore.value.length === 0)) {
unitStore = actions.getAll();
}
return { units: unitStore, actions };
};
return useStore<IngredientUnit>(store, loading, api.units);
}

View File

@ -1,5 +1,5 @@
import { computed, ref, Ref, useAsync } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { useAdminApi, useUserApi } from "~/composables/api";
import { HouseholdCreate, HouseholdInDB } from "~/lib/api/types/household";
const householdSelfRef = ref<HouseholdInDB | null>(null);
@ -46,8 +46,8 @@ export const useHouseholdSelf = function () {
return { actions, household };
};
export const useHouseholds = function () {
const api = useUserApi();
export const useAdminHouseholds = function () {
const api = useAdminApi();
const loading = ref(false);
function getAllHouseholds() {

View File

@ -7,34 +7,37 @@ const loading = ref(false);
const ready = ref(false);
export const useUserSelfRatings = function () {
const { $auth } = useContext();
const api = useUserApi();
const { $auth } = useContext();
const api = useUserApi();
async function refreshUserRatings() {
if (loading.value) {
return;
}
loading.value = true;
const { data } = await api.users.getSelfRatings();
userRatings.value = data?.ratings || [];
loading.value = false;
ready.value = true;
async function refreshUserRatings() {
if (!$auth.user || loading.value) {
return;
}
async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) {
loading.value = true;
const userId = $auth.user?.id || "";
await api.users.setRating(userId, slug, rating, isFavorite);
loading.value = false;
await refreshUserRatings();
}
loading.value = true;
const { data } = await api.users.getSelfRatings();
userRatings.value = data?.ratings || [];
loading.value = false;
ready.value = true;
}
async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) {
loading.value = true;
const userId = $auth.user?.id || "";
await api.users.setRating(userId, slug, rating, isFavorite);
loading.value = false;
await refreshUserRatings();
}
if (!ready.value) {
refreshUserRatings();
return {
userRatings,
refreshUserRatings,
setRating,
ready,
}
}
return {
userRatings,
refreshUserRatings,
setRating,
ready,
}
}

View File

@ -652,6 +652,7 @@
"or": "Or",
"has-any": "Has Any",
"has-all": "Has All",
"clear-selection": "Clear Selection",
"results": "Results",
"search": "Search",
"search-mealie": "Search Mealie (press /)",

View File

@ -0,0 +1,13 @@
import { BaseCRUDAPI } from "../base/base-clients";
import { HouseholdCreate, HouseholdInDB, UpdateHouseholdAdmin } from "~/lib/api/types/household";
const prefix = "/api";
const routes = {
adminHouseholds: `${prefix}/admin/households`,
adminHouseholdsId: (id: string) => `${prefix}/admin/households/${id}`,
};
export class AdminHouseholdsApi extends BaseCRUDAPI<HouseholdCreate, HouseholdInDB, UpdateHouseholdAdmin> {
baseRoute: string = routes.adminHouseholds;
itemRoute = routes.adminHouseholdsId;
}

View File

@ -1,5 +1,6 @@
import { AdminAboutAPI } from "./admin/admin-about";
import { AdminUsersApi } from "./admin/admin-users";
import { AdminHouseholdsApi } from "./admin/admin-households";
import { AdminGroupsApi } from "./admin/admin-groups";
import { AdminBackupsApi } from "./admin/admin-backups";
import { AdminMaintenanceApi } from "./admin/admin-maintenance";
@ -9,6 +10,7 @@ import { ApiRequestInstance } from "~/lib/api/types/non-generated";
export class AdminAPI {
public about: AdminAboutAPI;
public users: AdminUsersApi;
public households: AdminHouseholdsApi;
public groups: AdminGroupsApi;
public backups: AdminBackupsApi;
public maintenance: AdminMaintenanceApi;
@ -17,6 +19,7 @@ export class AdminAPI {
constructor(requests: ApiRequestInstance) {
this.about = new AdminAboutAPI(requests);
this.users = new AdminUsersApi(requests);
this.households = new AdminHouseholdsApi(requests);
this.groups = new AdminGroupsApi(requests);
this.backups = new AdminBackupsApi(requests);
this.maintenance = new AdminMaintenanceApi(requests);

View File

@ -4,6 +4,7 @@ import { PublicRecipeApi } from "./explore/recipes";
import { PublicFoodsApi } from "./explore/foods";
import { PublicCategoriesApi, PublicTagsApi, PublicToolsApi } from "./explore/organizers";
import { PublicCookbooksApi } from "./explore/cookbooks";
import { PublicHouseholdApi } from "./explore/households";
export class ExploreApi extends BaseAPI {
public recipes: PublicRecipeApi;
@ -12,6 +13,7 @@ export class ExploreApi extends BaseAPI {
public categories: PublicCategoriesApi;
public tags: PublicTagsApi;
public tools: PublicToolsApi;
public households: PublicHouseholdApi
constructor(requests: ApiRequestInstance, groupSlug: string) {
super(requests);
@ -21,5 +23,6 @@ export class ExploreApi extends BaseAPI {
this.categories = new PublicCategoriesApi(requests, groupSlug);
this.tags = new PublicTagsApi(requests, groupSlug);
this.tools = new PublicToolsApi(requests, groupSlug);
this.households = new PublicHouseholdApi(requests, groupSlug);
}
}

View File

@ -0,0 +1,20 @@
import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
import { HouseholdSummary } from "~/lib/api/types/household";
import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
const prefix = "/api";
const exploreGroupSlug = (groupSlug: string | number) => `${prefix}/explore/groups/${groupSlug}`
const routes = {
householdsGroupSlug: (groupSlug: string | number) => `${exploreGroupSlug(groupSlug)}/households`,
householdsGroupSlugHouseholdSlug: (groupSlug: string | number, householdSlug: string | number) => `${exploreGroupSlug(groupSlug)}/households/${householdSlug}`,
};
export class PublicHouseholdApi extends BaseCRUDAPIReadOnly<HouseholdSummary> {
baseRoute = routes.householdsGroupSlug(this.groupSlug);
itemRoute = (itemId: string | number) => routes.householdsGroupSlugHouseholdSlug(this.groupSlug, itemId);
constructor(requests: ApiRequestInstance, private readonly groupSlug: string) {
super(requests);
}
}

View File

@ -1,6 +1,5 @@
import { BaseCRUDAPI } from "../base/base-clients";
import { GroupBase, GroupInDB, GroupSummary, UserSummary } from "~/lib/api/types/user";
import { HouseholdSummary } from "~/lib/api/types/household";
import {
GroupAdminUpdate,
GroupStorage,
@ -15,8 +14,6 @@ const routes = {
groupsSelf: `${prefix}/groups/self`,
preferences: `${prefix}/groups/preferences`,
storage: `${prefix}/groups/storage`,
households: `${prefix}/groups/households`,
householdsId: (id: string | number) => `${prefix}/groups/households/${id}`,
membersHouseholdId: (householdId: string | number | null) => {
return householdId ?
`${prefix}/households/members?householdId=${householdId}` :
@ -47,14 +44,6 @@ export class GroupAPI extends BaseCRUDAPI<GroupBase, GroupInDB, GroupAdminUpdate
return await this.requests.get<UserSummary[]>(routes.membersHouseholdId(householdId));
}
async fetchHouseholds() {
return await this.requests.get<HouseholdSummary[]>(routes.households);
}
async fetchHousehold(householdId: string | number) {
return await this.requests.get<HouseholdSummary>(routes.householdsId(householdId));
}
async storage() {
return await this.requests.get<GroupStorage>(routes.storage);
}

View File

@ -1,21 +1,20 @@
import { BaseCRUDAPI } from "../base/base-clients";
import { BaseCRUDAPIReadOnly } from "../base/base-clients";
import { UserOut } from "~/lib/api/types/user";
import {
HouseholdCreate,
HouseholdInDB,
UpdateHouseholdAdmin,
HouseholdStatistics,
ReadHouseholdPreferences,
SetPermissions,
UpdateHouseholdPreferences,
CreateInviteToken,
ReadInviteToken,
HouseholdSummary,
} from "~/lib/api/types/household";
const prefix = "/api";
const routes = {
households: `${prefix}/admin/households`,
households: `${prefix}/groups/households`,
householdsSelf: `${prefix}/households/self`,
members: `${prefix}/households/members`,
permissions: `${prefix}/households/permissions`,
@ -24,13 +23,13 @@ const routes = {
statistics: `${prefix}/households/statistics`,
invitation: `${prefix}/households/invitations`,
householdsId: (id: string | number) => `${prefix}/admin/households/${id}`,
householdsId: (id: string | number) => `${prefix}/groups/households/${id}`,
};
export class HouseholdAPI extends BaseCRUDAPI<HouseholdCreate, HouseholdInDB, UpdateHouseholdAdmin> {
export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> {
baseRoute = routes.households;
itemRoute = routes.householdsId;
/** Returns the Group Data for the Current User
/** Returns the Household Data for the Current User
*/
async getCurrentUserHousehold() {
return await this.requests.get<HouseholdInDB>(routes.householdsSelf);

View File

@ -56,13 +56,14 @@ const routes = {
};
export type RecipeSearchQuery = {
search: string;
search?: string;
orderDirection?: "asc" | "desc";
groupId?: string;
queryFilter?: string;
cookbook?: string;
households?: string[];
categories?: string[];
requireAllCategories?: boolean;

View File

@ -45,7 +45,7 @@
import { defineComponent, useRoute, onMounted, ref, useContext } from "@nuxtjs/composition-api";
import HouseholdPreferencesEditor from "~/components/Domain/Household/HouseholdPreferencesEditor.vue";
import { useGroups } from "~/composables/use-groups";
import { useUserApi } from "~/composables/api";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { validators } from "~/composables/use-validators";
import { HouseholdInDB } from "~/lib/api/types/household";
@ -68,14 +68,14 @@ export default defineComponent({
const refHouseholdEditForm = ref<VForm | null>(null);
const userApi = useUserApi();
const adminApi = useAdminApi();
const household = ref<HouseholdInDB | null>(null);
const userError = ref(false);
onMounted(async () => {
const { data, error } = await userApi.households.getOne(householdId);
const { data, error } = await adminApi.households.getOne(householdId);
if (error?.response?.status === 404) {
alert.error(i18n.tc("user.user-not-found"));
@ -92,7 +92,7 @@ export default defineComponent({
return;
}
const { response, data } = await userApi.households.updateOne(household.value.id, household.value);
const { response, data } = await adminApi.households.updateOne(household.value.id, household.value);
if (response?.status === 200 && data) {
household.value = data;
alert.success(i18n.tc("settings.settings-updated"));

View File

@ -88,7 +88,7 @@
import { defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
import { fieldTypes } from "~/composables/forms";
import { useGroups } from "~/composables/use-groups";
import { useHouseholds } from "~/composables/use-households";
import { useAdminHouseholds } from "~/composables/use-households";
import { validators } from "~/composables/use-validators";
import { HouseholdInDB } from "~/lib/api/types/household";
@ -97,7 +97,7 @@ export default defineComponent({
setup() {
const { i18n } = useContext();
const { groups } = useGroups();
const { households, refreshAllHouseholds, deleteHousehold, createHousehold } = useHouseholds();
const { households, refreshAllHouseholds, deleteHousehold, createHousehold } = useAdminHouseholds();
const state = reactive({
createDialog: false,

View File

@ -80,7 +80,7 @@
import { computed, defineComponent, useRoute, onMounted, ref, useContext } from "@nuxtjs/composition-api";
import { useAdminApi, useUserApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups";
import { useHouseholds } from "~/composables/use-households";
import { useAdminHouseholds } from "~/composables/use-households";
import { alert } from "~/composables/use-toast";
import { useUserForm } from "~/composables/use-users";
import { validators } from "~/composables/use-validators";
@ -92,7 +92,7 @@ export default defineComponent({
setup() {
const { userForm } = useUserForm();
const { groups } = useGroups();
const { useHouseholdsInGroup } = useHouseholds();
const { useHouseholdsInGroup } = useAdminHouseholds();
const { i18n } = useContext();
const route = useRoute();

View File

@ -50,7 +50,7 @@
import { computed, defineComponent, useRouter, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups";
import { useHouseholds } from "~/composables/use-households";
import { useAdminHouseholds } from "~/composables/use-households";
import { useUserForm } from "~/composables/use-users";
import { validators } from "~/composables/use-validators";
import { VForm } from "~/types/vuetify";
@ -60,7 +60,7 @@ export default defineComponent({
setup() {
const { userForm } = useUserForm();
const { groups } = useGroups();
const { useHouseholdsInGroup } = useHouseholds();
const { useHouseholdsInGroup } = useAdminHouseholds();
const router = useRouter();
// ==============================================

View File

@ -94,7 +94,7 @@
<script lang="ts">
import { computed, defineComponent, ref, useContext, useRouter } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { useAdminApi, useUserApi } from "~/composables/api";
import { useLocales } from "~/composables/use-locales";
import { alert } from "~/composables/use-toast";
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
@ -108,7 +108,8 @@ export default defineComponent({
// ================================================================
// Setup
const { $auth, $globals, i18n } = useContext();
const api = useUserApi();
const userApi = useUserApi();
const adminApi = useAdminApi();
const groupSlug = computed(() => $auth.user?.groupSlug);
const { locale } = useLocales();
@ -264,7 +265,7 @@ export default defineComponent({
async function updateUser() {
// @ts-ignore-next-line user will never be null here
const { response } = await api.users.updateOne($auth.user?.id, {
const { response } = await userApi.users.updateOne($auth.user?.id, {
...$auth.user,
email: accountDetails.email.value,
username: accountDetails.username.value,
@ -285,7 +286,7 @@ export default defineComponent({
}
async function updatePassword() {
const { response } = await api.users.changePassword({
const { response } = await userApi.users.changePassword({
currentPassword: "MyPassword",
newPassword: credentials.password1.value,
});
@ -303,7 +304,7 @@ export default defineComponent({
async function updateGroup() {
// @ts-ignore-next-line user will never be null here
const { data } = await api.groups.getOne($auth.user?.groupId);
const { data } = await userApi.groups.getOne($auth.user?.groupId);
if (!data || !data.preferences) {
alert.error(i18n.tc("events.something-went-wrong"));
return;
@ -320,7 +321,7 @@ export default defineComponent({
}
// @ts-ignore-next-line user will never be null here
const { response } = await api.groups.updateOne($auth.user?.groupId, payload);
const { response } = await userApi.groups.updateOne($auth.user?.groupId, payload);
if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong"));
}
@ -328,7 +329,7 @@ export default defineComponent({
async function updateHousehold() {
// @ts-ignore-next-line user will never be null here
const { data } = await api.households.getOne($auth.user?.householdId);
const { data } = await adminApi.households.getOne($auth.user?.householdId);
if (!data || !data.preferences) {
alert.error(i18n.tc("events.something-went-wrong"));
return;
@ -346,28 +347,28 @@ export default defineComponent({
}
// @ts-ignore-next-line user will never be null here
const { response } = await api.households.updateOne($auth.user?.householdId, payload);
const { response } = await adminApi.households.updateOne($auth.user?.householdId, payload);
if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong"));
}
}
async function seedFoods() {
const { response } = await api.seeders.foods({ locale: locale.value })
const { response } = await userApi.seeders.foods({ locale: locale.value })
if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong"));
}
}
async function seedUnits() {
const { response } = await api.seeders.units({ locale: locale.value })
const { response } = await userApi.seeders.units({ locale: locale.value })
if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong"));
}
}
async function seedLabels() {
const { response } = await api.seeders.labels({ locale: locale.value })
const { response } = await userApi.seeders.labels({ locale: locale.value })
if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong"));
}

View File

@ -272,12 +272,10 @@ export default defineComponent({
const errors = ref<Error[]>([]);
function checkForUnit(unit?: IngredientUnit | CreateIngredientUnit) {
// @ts-expect-error; we're just checking if there's an id on this unit and returning a boolean
return !!unit?.id;
}
function checkForFood(food?: IngredientFood | CreateIngredientFood) {
// @ts-expect-error; we're just checking if there's an id on this food and returning a boolean
return !!food?.id;
}

View File

@ -1,8 +1,8 @@
<template>
<v-container>
<RecipeOrganizerPage
v-if="items"
:items="items"
v-if="store"
:items="store"
:icon="$globals.icons.categories"
item-type="categories"
@delete="actions.deleteOne"
@ -24,10 +24,10 @@ export default defineComponent({
},
middleware: ["auth", "group-only"],
setup() {
const { items, actions } = useCategoryStore();
const { store, actions } = useCategoryStore();
return {
items,
store,
actions,
};
},

View File

@ -1,8 +1,8 @@
<template>
<v-container>
<RecipeOrganizerPage
v-if="items"
:items="items"
v-if="store"
:items="store"
:icon="$globals.icons.tags"
item-type="tags"
@delete="actions.deleteOne"
@ -24,10 +24,10 @@ export default defineComponent({
},
middleware: ["auth", "group-only"],
setup() {
const { items, actions } = useTagStore();
const { store, actions } = useTagStore();
return {
items,
store,
actions,
};
},

View File

@ -29,7 +29,7 @@ export default defineComponent({
return {
dialog,
tools: toolStore.items,
tools: toolStore.store,
actions: toolStore.actions,
};
},

View File

@ -81,6 +81,7 @@
:headers.sync="tableHeaders"
:data="categories || []"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="name"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler"
@ -198,7 +199,7 @@ export default defineComponent({
state,
tableConfig,
tableHeaders,
categories: categoryStore.items,
categories: categoryStore.store,
validators,
// create

View File

@ -241,6 +241,8 @@
{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'},
{icon: $globals.icons.tags, text: $tc('data-pages.labels.assign-label'), event: 'assign-selected'}
]"
initial-sort="createdAt"
initial-sort-desc
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@create-one="createEventHandler"
@ -264,6 +266,9 @@
{{ item.onHand ? $globals.icons.check : $globals.icons.close }}
</v-icon>
</template>
<template #item.createdAt="{ item }">
{{ formatDate(item.createdAt) }}
</template>
<template #button-bottom>
<BaseButton @click="seedDialog = true">
<template #icon> {{ $globals.icons.database }} </template>
@ -326,8 +331,21 @@ export default defineComponent({
value: "onHand",
show: true,
},
{
text: i18n.tc("general.date-added"),
value: "createdAt",
show: false,
}
];
function formatDate(date: string) {
try {
return i18n.d(Date.parse(date), "medium");
} catch {
return "";
}
}
const foodStore = useFoodStore();
// ===============================================================
@ -453,7 +471,7 @@ export default defineComponent({
// ============================================================
// Labels
const { labels: allLabels } = useLabelStore();
const { store: allLabels } = useLabelStore();
// ============================================================
// Seed
@ -501,16 +519,15 @@ export default defineComponent({
bulkAssignTarget.value = [];
bulkAssignLabelId.value = undefined;
foodStore.actions.refresh();
// reload page, because foodStore.actions.refresh() does not update the table, reactivity for this seems to be broken (again)
document.location.reload();
}
return {
tableConfig,
tableHeaders,
foods: foodStore.foods,
foods: foodStore.store,
allLabels,
validators,
formatDate,
// Create
createDialog,
domNewFoodForm,

View File

@ -115,6 +115,7 @@
:headers.sync="tableHeaders"
:data="labels || []"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="name"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler"
@ -271,7 +272,7 @@ export default defineComponent({
state,
tableConfig,
tableHeaders,
labels: labelStore.labels,
labels: labelStore.store,
validators,
// create

View File

@ -101,6 +101,7 @@
:headers.sync="tableHeaders"
:data="actions || []"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="title"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler"

View File

@ -81,6 +81,7 @@
:headers.sync="tableHeaders"
:data="tags || []"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="name"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler"
@ -199,7 +200,7 @@ export default defineComponent({
state,
tableConfig,
tableHeaders,
tags: tagStore.items,
tags: tagStore.store,
validators,
// create

View File

@ -83,6 +83,7 @@
:headers.sync="tableHeaders"
:data="tools || []"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="name"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler"
@ -209,7 +210,7 @@ export default defineComponent({
state,
tableConfig,
tableHeaders,
tools: toolStore.items,
tools: toolStore.store,
validators,
// create

View File

@ -9,11 +9,11 @@
</template>
</i18n>
<v-autocomplete v-model="fromUnit" return-object :items="units" item-text="id" :label="$t('data-pages.units.source-unit')">
<v-autocomplete v-model="fromUnit" return-object :items="store" item-text="id" :label="$t('data-pages.units.source-unit')">
<template #selection="{ item }"> {{ item.name }}</template>
<template #item="{ item }"> {{ item.name }} </template>
</v-autocomplete>
<v-autocomplete v-model="toUnit" return-object :items="units" item-text="id" :label="$t('data-pages.units.target-unit')">
<v-autocomplete v-model="toUnit" return-object :items="store" item-text="id" :label="$t('data-pages.units.target-unit')">
<template #selection="{ item }"> {{ item.name }}</template>
<template #item="{ item }"> {{ item.name }} </template>
</v-autocomplete>
@ -185,7 +185,7 @@
</template>
</v-autocomplete>
<v-alert v-if="units && units.length > 0" type="error" class="mb-0 text-body-2">
<v-alert v-if="store && store.length > 0" type="error" class="mb-0 text-body-2">
{{ $t("data-pages.foods.seed-dialog-warning") }}
</v-alert>
</v-card-text>
@ -196,8 +196,10 @@
<CrudTable
:table-config="tableConfig"
:headers.sync="tableHeaders"
:data="units || []"
:data="store"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
initial-sort="createdAt"
initial-sort-desc
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@create-one="createEventHandler"
@ -221,6 +223,9 @@
{{ item.fraction ? $globals.icons.check : $globals.icons.close }}
</v-icon>
</template>
<template #item.createdAt="{ item }">
{{ formatDate(item.createdAt) }}
</template>
<template #button-bottom>
<BaseButton @click="seedDialog = true">
<template #icon> {{ $globals.icons.database }} </template>
@ -292,9 +297,22 @@ export default defineComponent({
value: "fraction",
show: true,
},
{
text: i18n.tc("general.date-added"),
value: "createdAt",
show: false,
},
];
const { units, actions: unitActions } = useUnitStore();
function formatDate(date: string) {
try {
return i18n.d(Date.parse(date), "medium");
} catch {
return "";
}
}
const { store, actions: unitActions } = useUnitStore();
// ============================================================
// Create Units
@ -447,8 +465,9 @@ export default defineComponent({
return {
tableConfig,
tableHeaders,
units,
store,
validators,
formatDate,
// Create
createDialog,
domNewUnitForm,

View File

@ -602,9 +602,9 @@ export default defineComponent({
const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>()
const { labels: allLabels } = useLabelStore();
const { units: allUnits } = useUnitStore();
const { foods: allFoods } = useFoodStore();
const { store: allLabels } = useLabelStore();
const { store: allUnits } = useUnitStore();
const { store: allFoods } = useFoodStore();
function getLabelColor(item: ShoppingListItemOut | null) {
return item?.label?.color;

View File

@ -5,34 +5,40 @@
:icon="$globals.icons.heart"
:title="$tc('user.user-favorites')"
:recipes="recipes"
:query="query"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
@delete="removeRecipe"
/>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useAsync, useRoute } from "@nuxtjs/composition-api";
import { defineComponent, useRoute } from "@nuxtjs/composition-api";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useLazyRecipes } from "~/composables/recipes";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
export default defineComponent({
components: { RecipeCardSection },
middleware: "auth",
setup() {
const api = useUserApi();
const route = useRoute();
const { isOwnGroup } = useLoggedInState();
const userId = route.value.params.id;
const recipes = useAsync(async () => {
const { data } = await api.recipes.getAll(1, -1, { queryFilter: `favoritedBy.id = "${userId}"` });
return data?.items || null;
}, useAsyncKey());
const query = { queryFilter: `favoritedBy.id = "${userId}"` }
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
return {
query,
recipes,
isOwnGroup,
appendRecipes,
assignSorted,
removeRecipe,
replaceRecipes,
};
},
head() {

View File

@ -10,6 +10,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import InstrumentedAttribute
from typing_extensions import Self
from mealie.db.models.household.household import Household
from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
from mealie.db.models.recipe.recipe import RecipeModel
@ -155,6 +156,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
tags: list[UUID4 | str] | None = None,
tools: list[UUID4 | str] | None = None,
foods: list[UUID4 | str] | None = None,
households: list[UUID4 | str] | None = None,
require_all_categories=True,
require_all_tags=True,
require_all_tools=True,
@ -170,6 +172,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
if cookbook:
cb_filters = self._build_recipe_filter(
households=[cookbook.household_id],
categories=extract_uuids(cookbook.categories),
tags=extract_uuids(cookbook.tags),
tools=extract_uuids(cookbook.tools),
@ -183,11 +186,13 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
category_ids = self._uuids_for_items(categories, Category)
tag_ids = self._uuids_for_items(tags, Tag)
tool_ids = self._uuids_for_items(tools, Tool)
household_ids = self._uuids_for_items(households, Household)
filters = self._build_recipe_filter(
categories=category_ids,
tags=tag_ids,
tools=tool_ids,
foods=foods,
households=household_ids,
require_all_categories=require_all_categories,
require_all_tags=require_all_tags,
require_all_tools=require_all_tools,
@ -245,6 +250,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
tags: list[UUID4] | None = None,
tools: list[UUID4] | None = None,
foods: list[UUID4] | None = None,
households: list[UUID4] | None = None,
require_all_categories: bool = True,
require_all_tags: bool = True,
require_all_tools: bool = True,
@ -278,6 +284,8 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
fltr.extend(RecipeModel.recipe_ingredient.any(RecipeIngredientModel.food_id == food) for food in foods)
else:
fltr.append(RecipeModel.recipe_ingredient.any(RecipeIngredientModel.food_id.in_(foods)))
if households:
fltr.append(RecipeModel.household_id.in_(households))
return fltr
def by_category_and_tags(

View File

@ -1,7 +1,7 @@
from abc import ABC
from logging import Logger
from fastapi import Depends
from fastapi import Depends, HTTPException
from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import Session
@ -97,6 +97,12 @@ class BasePublicGroupExploreController(BasePublicController):
def group_id(self) -> UUID4 | None | NotSet:
return self.group.id
def get_public_household(self, household_slug_or_id: str | UUID4) -> HouseholdInDB:
household = self.repos.households.get_by_slug_or_id(household_slug_or_id)
if not household or household.preferences.private_household:
raise HTTPException(404, "household not found")
return household
def get_explore_url_path(self, endpoint: str) -> str:
if endpoint.startswith("/"):
endpoint = endpoint[1:]

View File

@ -3,6 +3,7 @@ from fastapi import APIRouter
from . import (
controller_public_cookbooks,
controller_public_foods,
controller_public_households,
controller_public_organizers,
controller_public_recipes,
)
@ -11,6 +12,7 @@ router = APIRouter(prefix="/explore/groups/{group_slug}")
# group
router.include_router(controller_public_foods.router, tags=["Explore: Foods"])
router.include_router(controller_public_households.router, tags=["Explore: Households"])
router.include_router(controller_public_organizers.categories_router, tags=["Explore: Categories"])
router.include_router(controller_public_organizers.tags_router, tags=["Explore: Tags"])
router.include_router(controller_public_organizers.tools_router, tags=["Explore: Tools"])

View File

@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicGroupExploreController
from mealie.schema.household.household import HouseholdSummary
from mealie.schema.make_dependable import make_dependable
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
router = APIRouter(prefix="/households")
@controller(router)
class PublicHouseholdsController(BasePublicGroupExploreController):
@property
def households(self):
return self.repos.households
@router.get("", response_model=PaginationBase[HouseholdSummary])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery))
) -> PaginationBase[HouseholdSummary]:
public_filter = "(preferences.private_household = FALSE)"
if q.query_filter:
q.query_filter = f"({q.query_filter}) AND {public_filter}"
else:
q.query_filter = public_filter
response = self.households.page_all(pagination=q, override=HouseholdSummary)
response.set_pagination_guides(self.get_explore_url_path(router.url_path_for("get_all")), q.model_dump())
return response
@router.get("/{household_slug}", response_model=HouseholdSummary)
def get_household(self, household_slug: str) -> HouseholdSummary:
household = self.get_public_household(household_slug)
return household.cast(HouseholdSummary)

View File

@ -37,6 +37,7 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
tags: list[UUID4 | str] | None = Query(None),
tools: list[UUID4 | str] | None = Query(None),
foods: list[UUID4 | str] | None = Query(None),
households: list[UUID4 | str] | None = Query(None),
) -> PaginationBase[RecipeSummary]:
cookbook_data: ReadCookBook | None = None
recipes_repo = self.cross_household_recipes
@ -76,6 +77,7 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
tags=tags,
tools=tools,
foods=foods,
households=households,
require_all_categories=search_query.require_all_categories,
require_all_tags=search_query.require_all_tags,
require_all_tools=search_query.require_all_tools,

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter
from . import (
controller_group_households,
controller_group_reports,
controller_group_self_service,
controller_labels,
@ -10,6 +11,7 @@ from . import (
router = APIRouter()
router.include_router(controller_group_households.router)
router.include_router(controller_group_self_service.router)
router.include_router(controller_migrations.router)
router.include_router(controller_group_reports.router)

View File

@ -0,0 +1,27 @@
from fastapi import Depends, HTTPException
from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.household.household import HouseholdSummary
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
router = UserAPIRouter(prefix="/groups/households", tags=["Groups: Households"])
@controller(router)
class GroupHouseholdsController(BaseUserController):
@router.get("", response_model=PaginationBase[HouseholdSummary])
def get_all_households(self, q: PaginationQuery = Depends(PaginationQuery)):
response = self.repos.households.page_all(pagination=q, override=HouseholdSummary)
response.set_pagination_guides(router.url_path_for("get_all_households"), q.model_dump())
return response
@router.get("/{household_slug}", response_model=HouseholdSummary)
def get_one_household(self, household_slug: str):
household = self.repos.households.get_by_slug_or_id(household_slug)
if not household:
raise HTTPException(status_code=404, detail="Household not found")
return household.cast(HouseholdSummary)

View File

@ -1,6 +1,6 @@
from functools import cached_property
from fastapi import HTTPException, Query
from fastapi import Query
from pydantic import UUID4
from mealie.routes._base.base_controllers import BaseUserController
@ -8,9 +8,7 @@ from mealie.routes._base.controller import controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
from mealie.schema.group.group_statistics import GroupStorage
from mealie.schema.household.household import HouseholdSummary
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.responses import ErrorResponse
from mealie.schema.user.user import GroupSummary, UserSummary
from mealie.services.group_services.group_service import GroupService
@ -36,23 +34,6 @@ class GroupSelfServiceController(BaseUserController):
private_users = self.repos.users.page_all(PaginationQuery(page=1, per_page=-1, query_filter=query_filter)).items
return [user.cast(UserSummary) for user in private_users]
@router.get("/households", response_model=list[HouseholdSummary])
def get_group_households(self):
"""Returns all households belonging to the current group"""
households = self.repos.households.page_all(PaginationQuery(page=1, per_page=-1)).items
return [household.cast(HouseholdSummary) for household in households]
@router.get("/households/{slug}", response_model=HouseholdSummary)
def get_group_household(self, slug: str):
"""Returns a single household belonging to the current group"""
household = self.repos.households.get_by_slug_or_id(slug)
if not household:
raise HTTPException(status_code=404, detail=ErrorResponse.respond(message="No Entry Found"))
return household.cast(HouseholdSummary)
@router.get("/preferences", response_model=ReadGroupPreferences)
def get_group_preferences(self):
return self.group.preferences

View File

@ -320,6 +320,7 @@ class RecipeController(BaseRecipeController):
tags: list[UUID4 | str] | None = Query(None),
tools: list[UUID4 | str] | None = Query(None),
foods: list[UUID4 | str] | None = Query(None),
households: list[UUID4 | str] | None = Query(None),
):
cookbook_data: ReadCookBook | None = None
if search_query.cookbook:
@ -345,6 +346,7 @@ class RecipeController(BaseRecipeController):
tags=tags,
tools=tools,
foods=foods,
households=households,
require_all_categories=search_query.require_all_categories,
require_all_tags=search_query.require_all_tags,
require_all_tools=search_query.require_all_tools,

View File

@ -0,0 +1,81 @@
from uuid import UUID
import pytest
from fastapi.testclient import TestClient
from mealie.schema.household.household import HouseholdCreate
from mealie.schema.household.household_preferences import CreateHouseholdPreferences
from mealie.services.household_services.household_service import HouseholdService
from tests.utils import api_routes
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
@pytest.mark.parametrize("is_private_group", [True, False])
def test_get_all_households(api_client: TestClient, unique_user: TestUser, is_private_group: bool):
unique_user.repos.group_preferences.patch(UUID(unique_user.group_id), {"private_group": is_private_group})
households = [
HouseholdService.create_household(
unique_user.repos,
HouseholdCreate(name=random_string()),
CreateHouseholdPreferences(private_household=False),
)
for _ in range(5)
]
response = api_client.get(api_routes.explore_groups_group_slug_households(unique_user.group_id))
if is_private_group:
assert response.status_code == 404
else:
assert response.status_code == 200
response_ids = [item["id"] for item in response.json()["items"]]
for household in households:
assert str(household.id) in response_ids
@pytest.mark.parametrize("is_private_group", [True, False])
def test_get_all_households_public_only(api_client: TestClient, unique_user: TestUser, is_private_group: bool):
unique_user.repos.group_preferences.patch(UUID(unique_user.group_id), {"private_group": is_private_group})
public_household = HouseholdService.create_household(
unique_user.repos,
HouseholdCreate(name=random_string()),
CreateHouseholdPreferences(private_household=False),
)
private_household = HouseholdService.create_household(
unique_user.repos,
HouseholdCreate(name=random_string()),
CreateHouseholdPreferences(private_household=True),
)
response = api_client.get(api_routes.explore_groups_group_slug_households(unique_user.group_id))
if is_private_group:
assert response.status_code == 404
else:
assert response.status_code == 200
response_ids = [item["id"] for item in response.json()["items"]]
assert str(public_household.id) in response_ids
assert str(private_household.id) not in response_ids
@pytest.mark.parametrize("is_private_group", [True, False])
@pytest.mark.parametrize("is_private_household", [True, False])
def test_get_household(
api_client: TestClient, unique_user: TestUser, is_private_group: bool, is_private_household: bool
):
unique_user.repos.group_preferences.patch(UUID(unique_user.group_id), {"private_group": is_private_group})
household = household = HouseholdService.create_household(
unique_user.repos,
HouseholdCreate(name=random_string()),
CreateHouseholdPreferences(private_household=is_private_household),
)
response = api_client.get(
api_routes.explore_groups_group_slug_households_household_slug(unique_user.group_id, household.slug),
headers=unique_user.token,
)
if is_private_group or is_private_household:
assert response.status_code == 404
else:
assert response.status_code == 200
assert response.json()["id"] == str(household.id)

View File

@ -1,3 +1,5 @@
import random
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
@ -33,13 +35,10 @@ def test_get_group_members_filtered(api_client: TestClient, unique_user: TestUse
assert str(h2_user.user_id) in all_ids
def test_get_households(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser):
households = [
unfiltered_database.households.create({"name": random_string(), "group_id": unique_user.group_id})
for _ in range(5)
]
def test_get_households(api_client: TestClient, unique_user: TestUser):
households = [unique_user.repos.households.create({"name": random_string()}) for _ in range(5)]
response = api_client.get(api_routes.groups_households, headers=unique_user.token)
response_ids = [item["id"] for item in response.json()]
response_ids = [item["id"] for item in response.json()["items"]]
for household in households:
assert str(household.id) in response_ids
@ -58,23 +57,22 @@ def test_get_households_filtered(unfiltered_database: AllRepositories, api_clien
]
response = api_client.get(api_routes.groups_households, headers=unique_user.token)
response_ids = [item["id"] for item in response.json()]
response_ids = [item["id"] for item in response.json()["items"]]
for household in group_1_households:
assert str(household.id) in response_ids
for household in group_2_households:
assert str(household.id) not in response_ids
def test_get_household(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser):
group_1_id = unique_user.group_id
group_2_id = str(unfiltered_database.groups.create({"name": random_string()}).id)
def test_get_one_household(api_client: TestClient, unique_user: TestUser):
households = [unique_user.repos.households.create({"name": random_string()}) for _ in range(5)]
household = random.choice(households)
group_1_household = unfiltered_database.households.create({"name": random_string(), "group_id": group_1_id})
group_2_household = unfiltered_database.households.create({"name": random_string(), "group_id": group_2_id})
response = api_client.get(api_routes.groups_households_slug(group_1_household.slug), headers=unique_user.token)
response = api_client.get(api_routes.groups_households_household_slug(household.slug), headers=unique_user.token)
assert response.status_code == 200
assert response.json()["id"] == str(group_1_household.id)
assert response.json()["id"] == str(household.id)
response = api_client.get(api_routes.groups_households_slug(group_2_household.slug), headers=unique_user.token)
def test_get_one_household_not_found(api_client: TestClient, unique_user: TestUser):
response = api_client.get(api_routes.groups_households_household_slug(random_string()), headers=unique_user.token)
assert response.status_code == 404

View File

@ -1,4 +1,5 @@
import random
from collections.abc import Generator
from dataclasses import dataclass
from uuid import UUID
@ -35,19 +36,20 @@ class TestCookbook:
@pytest.fixture(scope="function")
def cookbooks(unique_user: TestUser) -> list[TestCookbook]:
def cookbooks(unique_user: TestUser) -> Generator[list[TestCookbook]]:
database = unique_user.repos
data: list[ReadCookBook] = []
yield_data: list[TestCookbook] = []
for _ in range(3):
cb = database.cookbooks.create(SaveCookBook(**get_page_data(unique_user.group_id, unique_user.household_id)))
assert cb.slug
data.append(cb)
yield_data.append(TestCookbook(id=cb.id, slug=cb.slug, name=cb.name, data=cb.model_dump()))
yield yield_data
for cb in yield_data:
for cb in data:
try:
database.cookbooks.delete(cb.id)
except Exception:

View File

@ -3,6 +3,9 @@ from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from mealie.schema.cookbook.cookbook import SaveCookBook
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_category import TagSave
from tests.utils import api_routes
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
@ -65,6 +68,38 @@ def test_get_all_recipes_includes_all_households(
assert str(h2_recipe_id) in response_ids
@pytest.mark.parametrize("is_private_household", [True, False])
def test_get_all_recipes_with_household_filter(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
):
household = unique_user.repos.households.get_one(h2_user.household_id)
assert household and household.preferences
household.preferences.private_household = is_private_household
unique_user.repos.household_preferences.update(household.id, household.preferences)
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token)
assert response.status_code == 201
recipe = unique_user.repos.recipes.get_one(response.json())
assert recipe and recipe.id
recipe_id = recipe.id
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
assert response.status_code == 201
h2_recipe = h2_user.repos.recipes.get_one(response.json())
assert h2_recipe and h2_recipe.id
h2_recipe_id = h2_recipe.id
response = api_client.get(
api_routes.recipes,
params={"households": [h2_recipe.household_id], "page": 1, "perPage": -1},
headers=unique_user.token,
)
assert response.status_code == 200
response_ids = {recipe["id"] for recipe in response.json()["items"]}
assert str(recipe_id) not in response_ids
assert str(h2_recipe_id) in response_ids
@pytest.mark.parametrize("is_private_household", [True, False])
def test_get_one_recipe_from_another_household(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
@ -220,3 +255,49 @@ def test_user_can_update_last_made_on_other_household(
assert recipe["id"] == str(h2_recipe_id)
new_last_made = recipe["lastMade"]
assert new_last_made == now != old_last_made
def test_cookbook_recipes_only_includes_current_households(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser
):
tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
recipes = unique_user.repos.recipes.create_many(
[
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
tags=[tag],
)
for _ in range(3)
]
)
other_recipes = h2_user.repos.recipes.create_many(
[
Recipe(
user_id=h2_user.user_id,
group_id=h2_user.group_id,
name=random_string(),
)
for _ in range(3)
]
)
cookbook = unique_user.repos.cookbooks.create(
SaveCookBook(
name=random_string(),
group_id=unique_user.group_id,
household_id=unique_user.household_id,
tags=[tag],
)
)
response = api_client.get(api_routes.recipes, params={"cookbook": cookbook.slug}, headers=unique_user.token)
assert response.status_code == 200
recipes = [Recipe.model_validate(data) for data in response.json()["items"]]
fetched_recipe_ids = {recipe.id for recipe in recipes}
for recipe in recipes:
assert recipe.id in fetched_recipe_ids
for recipe in other_recipes:
assert recipe.id not in fetched_recipe_ids

View File

@ -20,6 +20,7 @@ from recipe_scrapers.plugins import SchemaOrgFillPlugin
from slugify import slugify
from mealie.pkgs.safehttp.transport import AsyncSafeTransport
from mealie.schema.cookbook.cookbook import SaveCookBook
from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary, RecipeTag
from mealie.schema.recipe.recipe_category import CategorySave, TagSave
from mealie.schema.recipe.recipe_notes import RecipeNote
@ -791,3 +792,47 @@ def test_get_random_order(api_client: TestClient, unique_user: utils.TestUser):
badparams: dict[str, int | str] = {"page": 1, "perPage": -1, "orderBy": "random"}
response = api_client.get(api_routes.recipes, params=badparams, headers=unique_user.token)
assert response.status_code == 422
def test_get_cookbook_recipes(api_client: TestClient, unique_user: utils.TestUser):
tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
cookbook_recipes = unique_user.repos.recipes.create_many(
[
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
tags=[tag],
)
for _ in range(3)
]
)
other_recipes = unique_user.repos.recipes.create_many(
[
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
)
for _ in range(3)
]
)
cookbook = unique_user.repos.cookbooks.create(
SaveCookBook(
name=random_string(),
group_id=unique_user.group_id,
household_id=unique_user.household_id,
tags=[tag],
)
)
response = api_client.get(api_routes.recipes, params={"cookbook": cookbook.slug}, headers=unique_user.token)
assert response.status_code == 200
recipes = [Recipe.model_validate(data) for data in response.json()["items"]]
fetched_recipe_ids = {recipe.id for recipe in recipes}
for recipe in cookbook_recipes:
assert recipe.id in fetched_recipe_ids
for recipe in other_recipes:
assert recipe.id not in fetched_recipe_ids

View File

@ -247,6 +247,16 @@ def explore_groups_group_slug_foods_item_id(group_slug, item_id):
return f"{prefix}/explore/groups/{group_slug}/foods/{item_id}"
def explore_groups_group_slug_households(group_slug):
"""`/api/explore/groups/{group_slug}/households`"""
return f"{prefix}/explore/groups/{group_slug}/households"
def explore_groups_group_slug_households_household_slug(group_slug, household_slug):
"""`/api/explore/groups/{group_slug}/households/{household_slug}`"""
return f"{prefix}/explore/groups/{group_slug}/households/{household_slug}"
def explore_groups_group_slug_organizers_categories(group_slug):
"""`/api/explore/groups/{group_slug}/organizers/categories`"""
return f"{prefix}/explore/groups/{group_slug}/organizers/categories"
@ -292,9 +302,9 @@ def foods_item_id(item_id):
return f"{prefix}/foods/{item_id}"
def groups_households_slug(slug):
"""`/api/groups/households/{slug}`"""
return f"{prefix}/groups/households/{slug}"
def groups_households_household_slug(household_slug):
"""`/api/groups/households/{household_slug}`"""
return f"{prefix}/groups/households/{household_slug}"
def groups_labels_item_id(item_id):