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

feat: Move "on hand" and "last made" to household (#4616)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2025-01-13 10:19:49 -06:00 committed by GitHub
parent e565b919df
commit e9892aba89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1618 additions and 400 deletions

File diff suppressed because one or more lines are too long

View file

@ -204,6 +204,10 @@ export default defineComponent({
shoppingListShowAllToggled: false, shoppingListShowAllToggled: false,
}); });
const userHousehold = computed(() => {
return $auth.user?.householdSlug || "";
});
const shoppingListChoices = computed(() => { const shoppingListChoices = computed(() => {
return props.shoppingLists.filter((list) => preferences.value.viewAllLists || list.userId === $auth.user?.id); return props.shoppingLists.filter((list) => preferences.value.viewAllLists || list.userId === $auth.user?.id);
}); });
@ -248,8 +252,9 @@ export default defineComponent({
} }
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => { const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
return { return {
checked: !ing.food?.onHand, checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing, ingredient: ing,
disableAmount: recipe.settings?.disableAmount || false, disableAmount: recipe.settings?.disableAmount || false,
} }
@ -276,7 +281,8 @@ export default defineComponent({
} }
// Store the on-hand ingredients for later // Store the on-hand ingredients for later
if (ing.ingredient.food?.onHand) { const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
if (householdsWithFood.includes(userHousehold.value)) {
onHandIngs.push(ing); onHandIngs.push(ing);
return sections; return sections;
} }

View file

@ -96,7 +96,12 @@
<v-icon left> <v-icon left>
{{ $globals.icons.calendar }} {{ $globals.icons.calendar }}
</v-icon> </v-icon>
{{ $t('recipe.last-made-date', { date: value ? new Date(value).toLocaleDateString($i18n.locale) : $t("general.never") } ) }} <div v-if="lastMadeReady">
{{ $t('recipe.last-made-date', { date: lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $t("general.never") } ) }}
</div>
<div v-else>
<AppLoader tiny />
</div>
</v-chip> </v-chip>
</div> </div>
<div class="d-flex justify-center flex-wrap mt-1"> <div class="d-flex justify-center flex-wrap mt-1">
@ -110,7 +115,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api"; import { computed, defineComponent, onMounted, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core"; import { whenever } from "@vueuse/core";
import { VForm } from "~/types/vuetify"; import { VForm } from "~/types/vuetify";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
@ -119,10 +124,6 @@ import { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
export default defineComponent({ export default defineComponent({
props: { props: {
value: {
type: String,
default: null,
},
recipe: { recipe: {
type: Object as () => Recipe, type: Object as () => Recipe,
required: true, required: true,
@ -146,6 +147,20 @@ export default defineComponent({
const newTimelineEventImagePreviewUrl = ref<string>(); const newTimelineEventImagePreviewUrl = ref<string>();
const newTimelineEventTimestamp = ref<string>(); const newTimelineEventTimestamp = ref<string>();
const lastMade = ref(props.recipe.lastMade);
const lastMadeReady = ref(false);
onMounted(async () => {
if (!$auth.user?.householdSlug) {
lastMade.value = props.recipe.lastMade;
} else {
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
lastMade.value = data?.lastMade;
}
lastMadeReady.value = true;
});
whenever( whenever(
() => madeThisDialog.value, () => madeThisDialog.value,
() => { () => {
@ -195,11 +210,9 @@ export default defineComponent({
const newEvent = eventResponse.data; const newEvent = eventResponse.data;
// we also update the recipe's last made value // we also update the recipe's last made value
if (!props.value || newTimelineEvent.value.timestamp > props.value) { if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
lastMade.value = newTimelineEvent.value.timestamp;
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp); await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
// update recipe in parent so the user can see it
context.emit("input", newTimelineEvent.value.timestamp);
} }
// update the image, if provided // update the image, if provided
@ -234,6 +247,8 @@ export default defineComponent({
newTimelineEventImage, newTimelineEventImage,
newTimelineEventImagePreviewUrl, newTimelineEventImagePreviewUrl,
newTimelineEventTimestamp, newTimelineEventTimestamp,
lastMade,
lastMadeReady,
createTimelineEvent, createTimelineEvent,
clearImage, clearImage,
uploadImage, uploadImage,

View file

@ -30,7 +30,6 @@
<v-col cols="12" class="d-flex flex-wrap justify-center"> <v-col cols="12" class="d-flex flex-wrap justify-center">
<RecipeLastMade <RecipeLastMade
v-if="isOwnGroup" v-if="isOwnGroup"
:value="recipe.lastMade"
:recipe="recipe" :recipe="recipe"
:class="true ? undefined : 'force-bottom'" :class="true ? undefined : 'force-bottom'"
/> />

View file

@ -10,7 +10,7 @@
<h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2> <h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2>
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense> <v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
<v-checkbox <v-checkbox
v-model="recipe.tools[index].onHand" v-model="recipeTools[index].onHand"
hide-details hide-details
class="pt-0 my-auto py-auto" class="pt-0 my-auto py-auto"
color="secondary" color="secondary"
@ -26,14 +26,18 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"; import { computed, defineComponent } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { useToolStore } from "~/composables/store"; import { useToolStore } from "~/composables/store";
import { NoUndefinedField } from "~/lib/api/types/non-generated"; import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe"; import { Recipe, RecipeTool } from "~/lib/api/types/recipe";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue"; import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineComponent({ export default defineComponent({
components: { components: {
RecipeIngredients, RecipeIngredients,
@ -59,9 +63,31 @@ export default defineComponent({
const { user } = usePageUser(); const { user } = usePageUser();
const { isEditMode } = usePageState(props.recipe.slug); const { isEditMode } = usePageState(props.recipe.slug);
const recipeTools = computed(() => {
if (!(user.householdSlug && toolStore)) {
return props.recipe.tools.map((tool) => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
} else {
return props.recipe.tools.map((tool) => {
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
return { ...tool, onHand } as RecipeToolWithOnHand;
});
}
})
function updateTool(index: number) { function updateTool(index: number) {
if (user.id && toolStore) { if (user.id && user.householdSlug && toolStore) {
toolStore.actions.updateOne(props.recipe.tools[index]); const tool = recipeTools.value[index];
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
if (!tool.householdsWithTool) {
tool.householdsWithTool = [user.householdSlug];
} else {
tool.householdsWithTool.push(user.householdSlug);
}
} else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
tool.householdsWithTool = tool.householdsWithTool.filter((household) => household !== user.householdSlug);
}
toolStore.actions.updateOne(tool);
} else { } else {
console.log("no user, skipping server update"); console.log("no user, skipping server update");
} }
@ -69,6 +95,7 @@ export default defineComponent({
return { return {
toolStore, toolStore,
recipeTools,
isEditMode, isEditMode,
updateTool, updateTool,
}; };

View file

@ -8,14 +8,14 @@
</v-icon> </v-icon>
<div v-if="large" class="text-small"> <div v-if="large" class="text-small">
<slot> <slot>
{{ small ? "" : waitingText }} {{ (small || tiny) ? "" : waitingText }}
</slot> </slot>
</div> </div>
</div> </div>
</v-progress-circular> </v-progress-circular>
<div v-if="!large" class="text-small"> <div v-if="!large" class="text-small">
<slot> <slot>
{{ small ? "" : waitingTextCalculated }} {{ (small || tiny) ? "" : waitingTextCalculated }}
</slot> </slot>
</div> </div>
</div> </div>
@ -31,6 +31,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
tiny: {
type: Boolean,
default: false,
},
small: { small: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -50,6 +54,13 @@ export default defineComponent({
}, },
setup(props) { setup(props) {
const size = computed(() => { const size = computed(() => {
if (props.tiny) {
return {
width: 2,
icon: 0,
size: 25,
};
}
if (props.small) { if (props.small) {
return { return {
width: 2, width: 2,

View file

@ -9,7 +9,6 @@ export const useTools = function (eager = true) {
id: "", id: "",
name: "", name: "",
slug: "", slug: "",
onHand: false,
}); });
const api = useUserApi(); const api = useUserApi();

View file

@ -13,7 +13,6 @@ export const useFoodData = function () {
name: "", name: "",
description: "", description: "",
labelId: undefined, labelId: undefined,
onHand: false,
}); });
} }

View file

@ -3,16 +3,21 @@ import { useData, useReadOnlyStore, useStore } from "../partials/use-store-facto
import { RecipeTool } from "~/lib/api/types/recipe"; import { RecipeTool } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api"; import { usePublicExploreApi, useUserApi } from "~/composables/api";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
const store: Ref<RecipeTool[]> = ref([]); const store: Ref<RecipeTool[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const publicLoading = ref(false); const publicLoading = ref(false);
export const useToolData = function () { export const useToolData = function () {
return useData<RecipeTool>({ return useData<RecipeToolWithOnHand>({
id: "", id: "",
name: "", name: "",
slug: "", slug: "",
onHand: false, onHand: false,
householdsWithTool: [],
}); });
} }

View file

@ -161,7 +161,7 @@ export interface RecipeTool {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
onHand?: boolean; householdsWithTool?: string[];
[k: string]: unknown; [k: string]: unknown;
} }
export interface CustomPageImport { export interface CustomPageImport {

View file

@ -97,7 +97,7 @@ export interface RecipeTool {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
onHand?: boolean; householdsWithTool?: string[];
[k: string]: unknown; [k: string]: unknown;
} }
export interface SaveCookBook { export interface SaveCookBook {

View file

@ -208,6 +208,27 @@ export interface ReadWebhook {
householdId: string; householdId: string;
id: string; id: string;
} }
export interface HouseholdRecipeBase {
lastMade?: string | null;
}
export interface HouseholdRecipeCreate {
lastMade?: string | null;
householdId: string;
recipeId: string;
}
export interface HouseholdRecipeOut {
lastMade?: string | null;
householdId: string;
recipeId: string;
id: string;
}
export interface HouseholdRecipeSummary {
lastMade?: string | null;
recipeId: string;
}
export interface HouseholdRecipeUpdate {
lastMade?: string | null;
}
export interface HouseholdSave { export interface HouseholdSave {
groupId: string; groupId: string;
name: string; name: string;
@ -297,7 +318,6 @@ export interface IngredientUnit {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
fraction?: boolean; fraction?: boolean;
abbreviation?: string; abbreviation?: string;
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
@ -318,7 +338,6 @@ export interface CreateIngredientUnit {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
fraction?: boolean; fraction?: boolean;
abbreviation?: string; abbreviation?: string;
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
@ -338,9 +357,9 @@ export interface IngredientFood {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
labelId?: string | null; labelId?: string | null;
aliases?: IngredientFoodAlias[]; aliases?: IngredientFoodAlias[];
householdsWithIngredientFood?: string[];
label?: MultiPurposeLabelSummary | null; label?: MultiPurposeLabelSummary | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
@ -363,9 +382,9 @@ export interface CreateIngredientFood {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
labelId?: string | null; labelId?: string | null;
aliases?: CreateIngredientFoodAlias[]; aliases?: CreateIngredientFoodAlias[];
householdsWithIngredientFood?: string[];
[k: string]: unknown; [k: string]: unknown;
} }
export interface CreateIngredientFoodAlias { export interface CreateIngredientFoodAlias {
@ -592,7 +611,7 @@ export interface RecipeTool {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
onHand?: boolean; householdsWithTool?: string[];
[k: string]: unknown; [k: string]: unknown;
} }
export interface ShoppingListRemoveRecipeParams { export interface ShoppingListRemoveRecipeParams {

View file

@ -117,7 +117,7 @@ export interface RecipeTool {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
onHand?: boolean; householdsWithTool?: string[];
[k: string]: unknown; [k: string]: unknown;
} }
export interface SavePlanEntry { export interface SavePlanEntry {

View file

@ -64,9 +64,9 @@ export interface CreateIngredientFood {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
labelId?: string | null; labelId?: string | null;
aliases?: CreateIngredientFoodAlias[]; aliases?: CreateIngredientFoodAlias[];
householdsWithIngredientFood?: string[];
} }
export interface CreateIngredientFoodAlias { export interface CreateIngredientFoodAlias {
name: string; name: string;
@ -79,7 +79,6 @@ export interface CreateIngredientUnit {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
fraction?: boolean; fraction?: boolean;
abbreviation?: string; abbreviation?: string;
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
@ -136,9 +135,9 @@ export interface IngredientFood {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
labelId?: string | null; labelId?: string | null;
aliases?: IngredientFoodAlias[]; aliases?: IngredientFoodAlias[];
householdsWithIngredientFood?: string[];
label?: MultiPurposeLabelSummary | null; label?: MultiPurposeLabelSummary | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
@ -167,7 +166,6 @@ export interface IngredientUnit {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
fraction?: boolean; fraction?: boolean;
abbreviation?: string; abbreviation?: string;
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
@ -262,7 +260,7 @@ export interface RecipeTool {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
onHand?: boolean; householdsWithTool?: string[];
} }
export interface RecipeStep { export interface RecipeStep {
id?: string | null; id?: string | null;
@ -447,24 +445,24 @@ export interface RecipeTimelineEventUpdate {
} }
export interface RecipeToolCreate { export interface RecipeToolCreate {
name: string; name: string;
onHand?: boolean; householdsWithTool?: string[];
} }
export interface RecipeToolOut { export interface RecipeToolOut {
name: string; name: string;
onHand?: boolean; householdsWithTool?: string[];
id: string; id: string;
slug: string; slug: string;
} }
export interface RecipeToolResponse { export interface RecipeToolResponse {
name: string; name: string;
onHand?: boolean; householdsWithTool?: string[];
id: string; id: string;
slug: string; slug: string;
recipes?: RecipeSummary[]; recipes?: RecipeSummary[];
} }
export interface RecipeToolSave { export interface RecipeToolSave {
name: string; name: string;
onHand?: boolean; householdsWithTool?: string[];
groupId: string; groupId: string;
} }
export interface RecipeZipTokenResponse { export interface RecipeZipTokenResponse {
@ -478,9 +476,9 @@ export interface SaveIngredientFood {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
labelId?: string | null; labelId?: string | null;
aliases?: CreateIngredientFoodAlias[]; aliases?: CreateIngredientFoodAlias[];
householdsWithIngredientFood?: string[];
groupId: string; groupId: string;
} }
export interface SaveIngredientUnit { export interface SaveIngredientUnit {
@ -491,7 +489,6 @@ export interface SaveIngredientUnit {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
fraction?: boolean; fraction?: boolean;
abbreviation?: string; abbreviation?: string;
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
@ -536,7 +533,6 @@ export interface UnitFoodBase {
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
} | null; } | null;
onHand?: boolean;
} }
export interface UpdateImageResponse { export interface UpdateImageResponse {
image: string; image: string;

View file

@ -11,6 +11,7 @@ import {
CreateInviteToken, CreateInviteToken,
ReadInviteToken, ReadInviteToken,
HouseholdSummary, HouseholdSummary,
HouseholdRecipeSummary,
} from "~/lib/api/types/household"; } from "~/lib/api/types/household";
const prefix = "/api"; const prefix = "/api";
@ -26,6 +27,7 @@ const routes = {
invitation: `${prefix}/households/invitations`, invitation: `${prefix}/households/invitations`,
householdsId: (id: string | number) => `${prefix}/groups/households/${id}`, householdsId: (id: string | number) => `${prefix}/groups/households/${id}`,
householdsSelfRecipesSlug: (recipeSlug: string) => `${prefix}/households/self/recipes/${recipeSlug}`,
}; };
export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> { export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> {
@ -37,6 +39,10 @@ export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> {
return await this.requests.get<HouseholdInDB>(routes.householdsSelf); return await this.requests.get<HouseholdInDB>(routes.householdsSelf);
} }
async getCurrentUserHouseholdRecipe(recipeSlug: string) {
return await this.requests.get<HouseholdRecipeSummary>(routes.householdsSelfRecipesSlug(recipeSlug));
}
async getPreferences() { async getPreferences() {
return await this.requests.get<ReadHouseholdPreferences>(routes.preferences); return await this.requests.get<ReadHouseholdPreferences>(routes.preferences);
} }

View file

@ -5,8 +5,8 @@
:icon="$globals.icons.potSteam" :icon="$globals.icons.potSteam"
:items="tools" :items="tools"
item-type="tools" item-type="tools"
@delete="actions.deleteOne" @delete="deleteOne"
@update="actions.updateOne" @update="updateOne"
> >
<template #title> {{ $t("tool.tools") }} </template> <template #title> {{ $t("tool.tools") }} </template>
</RecipeOrganizerPage> </RecipeOrganizerPage>
@ -14,9 +14,14 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api"; import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeOrganizerPage from "~/components/Domain/Recipe/RecipeOrganizerPage.vue"; import RecipeOrganizerPage from "~/components/Domain/Recipe/RecipeOrganizerPage.vue";
import { useToolStore } from "~/composables/store"; import { useToolStore } from "~/composables/store";
import { RecipeTool } from "~/lib/api/types/recipe";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineComponent({ export default defineComponent({
components: { components: {
@ -24,13 +29,42 @@ export default defineComponent({
}, },
middleware: ["auth", "group-only"], middleware: ["auth", "group-only"],
setup() { setup() {
const { $auth } = useContext();
const toolStore = useToolStore(); const toolStore = useToolStore();
const dialog = ref(false); const dialog = ref(false);
const userHousehold = computed(() => $auth.user?.householdSlug || "");
const tools = computed(() => toolStore.store.value.map((tool) => (
{
...tool,
onHand: tool.householdsWithTool?.includes(userHousehold.value) || false
} as RecipeToolWithOnHand
)));
async function deleteOne(id: string | number) {
await toolStore.actions.deleteOne(id);
}
async function updateOne(tool: RecipeToolWithOnHand) {
if (userHousehold.value) {
if (tool.onHand && !tool.householdsWithTool?.includes(userHousehold.value)) {
if (!tool.householdsWithTool) {
tool.householdsWithTool = [userHousehold.value];
} else {
tool.householdsWithTool.push(userHousehold.value);
}
} else if (!tool.onHand && tool.householdsWithTool?.includes(userHousehold.value)) {
tool.householdsWithTool = tool.householdsWithTool.filter((household) => household !== userHousehold.value);
}
}
await toolStore.actions.updateOne(tool);
}
return { return {
dialog, dialog,
tools: toolStore.store, tools,
actions: toolStore.actions, deleteOne,
updateOne,
}; };
}, },
head() { head() {

View file

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- Merge Dialog --> <!-- Merge Dialog -->
<BaseDialog v-model="mergeDialog" :icon="$globals.icons.foods" :title="$t('data-pages.foods.combine-food')" @confirm="mergeFoods"> <BaseDialog v-model="mergeDialog" :icon="$globals.icons.foods" :title="$tc('data-pages.foods.combine-food')" @confirm="mergeFoods">
<v-card-text> <v-card-text>
<div> <div>
{{ $t("data-pages.foods.merge-dialog-text") }} {{ $t("data-pages.foods.merge-dialog-text") }}
@ -58,7 +58,7 @@
<BaseDialog <BaseDialog
v-model="createDialog" v-model="createDialog"
:icon="$globals.icons.foods" :icon="$globals.icons.foods"
:title="$t('data-pages.foods.create-food')" :title="$tc('data-pages.foods.create-food')"
:submit-icon="$globals.icons.save" :submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')" :submit-text="$tc('general.save')"
@submit="createFood" @submit="createFood"
@ -111,7 +111,7 @@
<BaseDialog <BaseDialog
v-model="editDialog" v-model="editDialog"
:icon="$globals.icons.foods" :icon="$globals.icons.foods"
:title="$t('data-pages.foods.edit-food')" :title="$tc('data-pages.foods.edit-food')"
:submit-icon="$globals.icons.save" :submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')" :submit-text="$tc('general.save')"
@submit="editSaveFood" @submit="editSaveFood"
@ -196,7 +196,7 @@
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<!-- Bulk Asign Labels Dialog --> <!-- Bulk Assign Labels Dialog -->
<BaseDialog <BaseDialog
v-model="bulkAssignLabelDialog" v-model="bulkAssignLabelDialog"
:title="$tc('data-pages.labels.assign-label')" :title="$tc('data-pages.labels.assign-label')"
@ -292,11 +292,20 @@ import { useFoodStore, useLabelStore } from "~/composables/store";
import { VForm } from "~/types/vuetify"; import { VForm } from "~/types/vuetify";
import { MultiPurposeLabelOut } from "~/lib/api/types/labels"; import { MultiPurposeLabelOut } from "~/lib/api/types/labels";
interface CreateIngredientFoodWithOnHand extends CreateIngredientFood {
onHand: boolean;
householdsWithIngredientFood: string[];
}
interface IngredientFoodWithOnHand extends IngredientFood {
onHand: boolean;
}
export default defineComponent({ export default defineComponent({
components: { MultiPurposeLabel, RecipeDataAliasManagerDialog }, components: { MultiPurposeLabel, RecipeDataAliasManagerDialog },
setup() { setup() {
const userApi = useUserApi(); const userApi = useUserApi();
const { i18n } = useContext(); const { $auth, i18n } = useContext();
const tableConfig = { const tableConfig = {
hideColumns: true, hideColumns: true,
canExport: true, canExport: true,
@ -352,15 +361,22 @@ export default defineComponent({
} }
} }
const userHousehold = computed(() => $auth.user?.householdSlug || "");
const foodStore = useFoodStore(); const foodStore = useFoodStore();
const foods = computed(() => foodStore.store.value.map((food) => {
const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false;
return { ...food, onHand } as IngredientFoodWithOnHand;
}));
// =============================================================== // ===============================================================
// Food Creator // Food Creator
const domNewFoodForm = ref<VForm>(); const domNewFoodForm = ref<VForm>();
const createDialog = ref(false); const createDialog = ref(false);
const createTarget = ref<CreateIngredientFood>({ const createTarget = ref<CreateIngredientFoodWithOnHand>({
name: "", name: "",
onHand: false,
householdsWithIngredientFood: [],
}); });
function createEventHandler() { function createEventHandler() {
@ -372,6 +388,10 @@ export default defineComponent({
return; return;
} }
if (createTarget.value.onHand) {
createTarget.value.householdsWithIngredientFood = [userHousehold.value];
}
// @ts-expect-error the createOne function erroneously expects an id because it uses the IngredientFood type // @ts-expect-error the createOne function erroneously expects an id because it uses the IngredientFood type
await foodStore.actions.createOne(createTarget.value); await foodStore.actions.createOne(createTarget.value);
createDialog.value = false; createDialog.value = false;
@ -379,6 +399,8 @@ export default defineComponent({
domNewFoodForm.value?.reset(); domNewFoodForm.value?.reset();
createTarget.value = { createTarget.value = {
name: "", name: "",
onHand: false,
householdsWithIngredientFood: [],
}; };
} }
@ -386,10 +408,11 @@ export default defineComponent({
// Food Editor // Food Editor
const editDialog = ref(false); const editDialog = ref(false);
const editTarget = ref<IngredientFood | null>(null); const editTarget = ref<IngredientFoodWithOnHand | null>(null);
function editEventHandler(item: IngredientFood) { function editEventHandler(item: IngredientFoodWithOnHand) {
editTarget.value = item; editTarget.value = item;
editTarget.value.onHand = item.householdsWithIngredientFood?.includes(userHousehold.value) || false;
editDialog.value = true; editDialog.value = true;
} }
@ -397,6 +420,17 @@ export default defineComponent({
if (!editTarget.value) { if (!editTarget.value) {
return; return;
} }
if (editTarget.value.onHand && !editTarget.value.householdsWithIngredientFood?.includes(userHousehold.value)) {
if (!editTarget.value.householdsWithIngredientFood) {
editTarget.value.householdsWithIngredientFood = [userHousehold.value];
} else {
editTarget.value.householdsWithIngredientFood.push(userHousehold.value);
}
} else if (!editTarget.value.onHand && editTarget.value.householdsWithIngredientFood?.includes(userHousehold.value)) {
editTarget.value.householdsWithIngredientFood = editTarget.value.householdsWithIngredientFood.filter(
(household) => household !== userHousehold.value
);
}
await foodStore.actions.updateOne(editTarget.value); await foodStore.actions.updateOne(editTarget.value);
editDialog.value = false; editDialog.value = false;
@ -406,8 +440,8 @@ export default defineComponent({
// Food Delete // Food Delete
const deleteDialog = ref(false); const deleteDialog = ref(false);
const deleteTarget = ref<IngredientFood | null>(null); const deleteTarget = ref<IngredientFoodWithOnHand | null>(null);
function deleteEventHandler(item: IngredientFood) { function deleteEventHandler(item: IngredientFoodWithOnHand) {
deleteTarget.value = item; deleteTarget.value = item;
deleteDialog.value = true; deleteDialog.value = true;
} }
@ -421,9 +455,9 @@ export default defineComponent({
} }
const bulkDeleteDialog = ref(false); const bulkDeleteDialog = ref(false);
const bulkDeleteTarget = ref<IngredientFood[]>([]); const bulkDeleteTarget = ref<IngredientFoodWithOnHand[]>([]);
function bulkDeleteEventHandler(selection: IngredientFood[]) { function bulkDeleteEventHandler(selection: IngredientFoodWithOnHand[]) {
bulkDeleteTarget.value = selection; bulkDeleteTarget.value = selection;
bulkDeleteDialog.value = true; bulkDeleteDialog.value = true;
} }
@ -455,8 +489,8 @@ export default defineComponent({
// Merge Foods // Merge Foods
const mergeDialog = ref(false); const mergeDialog = ref(false);
const fromFood = ref<IngredientFood | null>(null); const fromFood = ref<IngredientFoodWithOnHand | null>(null);
const toFood = ref<IngredientFood | null>(null); const toFood = ref<IngredientFoodWithOnHand | null>(null);
const canMerge = computed(() => { const canMerge = computed(() => {
return fromFood.value && toFood.value && fromFood.value.id !== toFood.value.id; return fromFood.value && toFood.value && fromFood.value.id !== toFood.value.id;
@ -506,10 +540,10 @@ export default defineComponent({
// ============================================================ // ============================================================
// Bulk Assign Labels // Bulk Assign Labels
const bulkAssignLabelDialog = ref(false); const bulkAssignLabelDialog = ref(false);
const bulkAssignTarget = ref<IngredientFood[]>([]); const bulkAssignTarget = ref<IngredientFoodWithOnHand[]>([]);
const bulkAssignLabelId = ref<string | undefined>(); const bulkAssignLabelId = ref<string | undefined>();
function bulkAssignEventHandler(selection: IngredientFood[]) { function bulkAssignEventHandler(selection: IngredientFoodWithOnHand[]) {
bulkAssignTarget.value = selection; bulkAssignTarget.value = selection;
bulkAssignLabelDialog.value = true; bulkAssignLabelDialog.value = true;
} }
@ -530,7 +564,7 @@ export default defineComponent({
return { return {
tableConfig, tableConfig,
tableHeaders, tableHeaders,
foods: foodStore.store, foods,
allLabels, allLabels,
validators, validators,
formatDate, formatDate,

View file

@ -109,11 +109,6 @@
<template #button-row> <template #button-row>
<BaseButton create @click="state.createDialog = true">{{ $t("general.create") }}</BaseButton> <BaseButton create @click="state.createDialog = true">{{ $t("general.create") }}</BaseButton>
</template> </template>
<template #item.onHand="{ item }">
<v-icon :color="item.onHand ? 'success' : undefined">
{{ item.onHand ? $globals.icons.check : $globals.icons.close }}
</v-icon>
</template>
</CrudTable> </CrudTable>
</div> </div>
</template> </template>

View file

@ -101,14 +101,18 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api"; import { computed, defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { useToolStore, useToolData } from "~/composables/store"; import { useToolStore, useToolData } from "~/composables/store";
import { RecipeTool } from "~/lib/api/types/admin"; import { RecipeTool } from "~/lib/api/types/recipe";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineComponent({ export default defineComponent({
setup() { setup() {
const { i18n } = useContext(); const { $auth, i18n } = useContext();
const tableConfig = { const tableConfig = {
hideColumns: true, hideColumns: true,
canExport: true, canExport: true,
@ -138,27 +142,38 @@ export default defineComponent({
bulkDeleteDialog: false, bulkDeleteDialog: false,
}); });
const userHousehold = computed(() => $auth.user?.householdSlug || "");
const toolData = useToolData(); const toolData = useToolData();
const toolStore = useToolStore(); const toolStore = useToolStore();
const tools = computed(() => toolStore.store.value.map((tools) => {
const onHand = tools.householdsWithTool?.includes(userHousehold.value) || false;
return { ...tools, onHand } as RecipeToolWithOnHand;
}));
// ============================================================ // ============================================================
// Create Tag // Create Tool
async function createTool() { async function createTool() {
if (toolData.data.onHand) {
toolData.data.householdsWithTool = [userHousehold.value];
} else {
toolData.data.householdsWithTool = [];
}
// @ts-ignore - only property really required is the name and onHand (RecipeOrganizerPage) // @ts-ignore - only property really required is the name and onHand (RecipeOrganizerPage)
await toolStore.actions.createOne({ name: toolData.data.name, onHand: toolData.data.onHand }); await toolStore.actions.createOne({ name: toolData.data.name, householdsWithTool: toolData.data.householdsWithTool });
toolData.reset(); toolData.reset();
state.createDialog = false; state.createDialog = false;
} }
// ============================================================ // ============================================================
// Edit Tag // Edit Tool
const editTarget = ref<RecipeTool | null>(null); const editTarget = ref<RecipeToolWithOnHand | null>(null);
function editEventHandler(item: RecipeTool) { function editEventHandler(item: RecipeToolWithOnHand) {
state.editDialog = true; state.editDialog = true;
editTarget.value = item; editTarget.value = item;
} }
@ -167,17 +182,29 @@ export default defineComponent({
if (!editTarget.value) { if (!editTarget.value) {
return; return;
} }
if (editTarget.value.onHand && !editTarget.value.householdsWithTool?.includes(userHousehold.value)) {
if (!editTarget.value.householdsWithTool) {
editTarget.value.householdsWithTool = [userHousehold.value];
} else {
editTarget.value.householdsWithTool.push(userHousehold.value);
}
} else if (!editTarget.value.onHand && editTarget.value.householdsWithTool?.includes(userHousehold.value)) {
editTarget.value.householdsWithTool = editTarget.value.householdsWithTool.filter(
(household) => household !== userHousehold.value
);
}
await toolStore.actions.updateOne(editTarget.value); await toolStore.actions.updateOne(editTarget.value);
state.editDialog = false; state.editDialog = false;
} }
// ============================================================ // ============================================================
// Delete Tag // Delete Tool
const deleteTarget = ref<RecipeTool | null>(null); const deleteTarget = ref<RecipeToolWithOnHand | null>(null);
function deleteEventHandler(item: RecipeTool) { function deleteEventHandler(item: RecipeToolWithOnHand) {
state.deleteDialog = true; state.deleteDialog = true;
deleteTarget.value = item; deleteTarget.value = item;
} }
@ -191,10 +218,10 @@ export default defineComponent({
} }
// ============================================================ // ============================================================
// Bulk Delete Tag // Bulk Delete Tool
const bulkDeleteTarget = ref<RecipeTool[]>([]); const bulkDeleteTarget = ref<RecipeToolWithOnHand[]>([]);
function bulkDeleteEventHandler(selection: RecipeTool[]) { function bulkDeleteEventHandler(selection: RecipeToolWithOnHand[]) {
bulkDeleteTarget.value = selection; bulkDeleteTarget.value = selection;
state.bulkDeleteDialog = true; state.bulkDeleteDialog = true;
} }
@ -210,7 +237,7 @@ export default defineComponent({
state, state,
tableConfig, tableConfig,
tableHeaders, tableHeaders,
tools: toolStore.store, tools,
validators, validators,
// create // create

View file

@ -0,0 +1,263 @@
"""add household to recipe last made, household to foods, and household to tools
Revision ID: b9e516e2d3b3
Revises: b1020f328e98
Create Date: 2024-11-20 17:30:41.152332
"""
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.orm import DeclarativeBase
import mealie.db.migration_types
from alembic import op
from mealie.core.root_logger import get_logger
from mealie.db.models._model_utils.datetime import NaiveDateTime
from mealie.db.models._model_utils.guid import GUID
# revision identifiers, used by Alembic.
revision = "b9e516e2d3b3"
down_revision: str | None = "b1020f328e98"
branch_labels: str | tuple[str, ...] | None = None
depends_on: str | tuple[str, ...] | None = None
logger = get_logger()
class SqlAlchemyBase(DeclarativeBase):
pass
# Intermediate table definitions
class Group(SqlAlchemyBase):
__tablename__ = "groups"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
class Household(SqlAlchemyBase):
__tablename__ = "households"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
class RecipeModel(SqlAlchemyBase):
__tablename__ = "recipes"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
last_made: orm.Mapped[datetime | None] = orm.mapped_column(NaiveDateTime)
class HouseholdToRecipe(SqlAlchemyBase):
__tablename__ = "households_to_recipes"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
household_id = sa.Column(GUID, sa.ForeignKey("households.id"), index=True, primary_key=True)
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"), index=True, primary_key=True)
last_made: orm.Mapped[datetime | None] = orm.mapped_column(NaiveDateTime)
class IngredientFoodModel(SqlAlchemyBase):
__tablename__ = "ingredient_foods"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
on_hand: orm.Mapped[bool] = orm.mapped_column(sa.Boolean, default=False)
class Tool(SqlAlchemyBase):
__tablename__ = "tools"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
on_hand: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=False)
def migrate_recipe_last_made_to_household(session: orm.Session):
for group in session.query(Group).all():
households = session.query(Household).filter(Household.group_id == group.id).all()
recipes = (
session.query(RecipeModel)
.filter(
RecipeModel.group_id == group.id,
RecipeModel.last_made != None, # noqa E711
)
.all()
)
for recipe in recipes:
for household in households:
session.add(
HouseholdToRecipe(
household_id=household.id,
recipe_id=recipe.id,
last_made=recipe.last_made,
)
)
def migrate_foods_on_hand_to_household(session: orm.Session):
dialect = op.get_bind().dialect
for group in session.query(Group).all():
households = session.query(Household).filter(Household.group_id == group.id).all()
foods = (
session.query(IngredientFoodModel)
.filter(
IngredientFoodModel.group_id == group.id,
IngredientFoodModel.on_hand == True, # noqa E712
)
.all()
)
for food in foods:
for household in households:
session.execute(
sa.text(
"INSERT INTO households_to_ingredient_foods (household_id, food_id)"
"VALUES (:household_id, :food_id)"
),
{
"household_id": GUID.convert_value_to_guid(household.id, dialect),
"food_id": GUID.convert_value_to_guid(food.id, dialect),
},
)
def migrate_tools_on_hand_to_household(session: orm.Session):
dialect = op.get_bind().dialect
for group in session.query(Group).all():
households = session.query(Household).filter(Household.group_id == group.id).all()
tools = (
session.query(Tool)
.filter(
Tool.group_id == group.id,
Tool.on_hand == True, # noqa E712
)
.all()
)
for tool in tools:
for household in households:
session.execute(
sa.text("INSERT INTO households_to_tools (household_id, tool_id) VALUES (:household_id, :tool_id)"),
{
"household_id": GUID.convert_value_to_guid(household.id, dialect),
"tool_id": GUID.convert_value_to_guid(tool.id, dialect),
},
)
def migrate_to_new_models():
bind = op.get_bind()
session = orm.Session(bind=bind)
for migration_func in [
migrate_recipe_last_made_to_household,
migrate_foods_on_hand_to_household,
migrate_tools_on_hand_to_household,
]:
try:
logger.info(f"Running new model migration ({migration_func.__name__})")
migration_func(session)
session.commit()
except Exception:
session.rollback()
logger.error(f"Error during new model migration ({migration_func.__name__})")
raise
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"households_to_recipes",
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("recipe_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("last_made", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.Column("created_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.Column("update_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.ForeignKeyConstraint(
["household_id"],
["households.id"],
),
sa.ForeignKeyConstraint(
["recipe_id"],
["recipes.id"],
),
sa.PrimaryKeyConstraint("id", "household_id", "recipe_id"),
sa.UniqueConstraint("household_id", "recipe_id", name="household_id_recipe_id_key"),
)
with op.batch_alter_table("households_to_recipes", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_households_to_recipes_created_at"), ["created_at"], unique=False)
batch_op.create_index(batch_op.f("ix_households_to_recipes_household_id"), ["household_id"], unique=False)
batch_op.create_index(batch_op.f("ix_households_to_recipes_recipe_id"), ["recipe_id"], unique=False)
op.create_table(
"households_to_tools",
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True),
sa.Column("tool_id", mealie.db.migration_types.GUID(), nullable=True),
sa.ForeignKeyConstraint(
["household_id"],
["households.id"],
),
sa.ForeignKeyConstraint(
["tool_id"],
["tools.id"],
),
sa.UniqueConstraint("household_id", "tool_id", name="household_id_tool_id_key"),
)
with op.batch_alter_table("households_to_tools", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_households_to_tools_household_id"), ["household_id"], unique=False)
batch_op.create_index(batch_op.f("ix_households_to_tools_tool_id"), ["tool_id"], unique=False)
op.create_table(
"households_to_ingredient_foods",
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True),
sa.Column("food_id", mealie.db.migration_types.GUID(), nullable=True),
sa.ForeignKeyConstraint(
["food_id"],
["ingredient_foods.id"],
),
sa.ForeignKeyConstraint(
["household_id"],
["households.id"],
),
sa.UniqueConstraint("household_id", "food_id", name="household_id_food_id_key"),
)
with op.batch_alter_table("households_to_ingredient_foods", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_households_to_ingredient_foods_food_id"), ["food_id"], unique=False)
batch_op.create_index(
batch_op.f("ix_households_to_ingredient_foods_household_id"), ["household_id"], unique=False
)
# ### end Alembic commands ###
migrate_to_new_models()
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("households_to_ingredient_foods", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_households_to_ingredient_foods_household_id"))
batch_op.drop_index(batch_op.f("ix_households_to_ingredient_foods_food_id"))
op.drop_table("households_to_ingredient_foods")
with op.batch_alter_table("households_to_tools", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_households_to_tools_tool_id"))
batch_op.drop_index(batch_op.f("ix_households_to_tools_household_id"))
op.drop_table("households_to_tools")
with op.batch_alter_table("households_to_recipes", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_households_to_recipes_recipe_id"))
batch_op.drop_index(batch_op.f("ix_households_to_recipes_household_id"))
batch_op.drop_index(batch_op.f("ix_households_to_recipes_created_at"))
op.drop_table("households_to_recipes")
# ### end Alembic commands ###

View file

@ -1,6 +1,7 @@
from .cookbook import CookBook from .cookbook import CookBook
from .events import GroupEventNotifierModel, GroupEventNotifierOptionsModel from .events import GroupEventNotifierModel, GroupEventNotifierOptionsModel
from .household import Household from .household import Household
from .household_to_recipe import HouseholdToRecipe
from .invite_tokens import GroupInviteToken from .invite_tokens import GroupInviteToken
from .mealplan import GroupMealPlan, GroupMealPlanRules from .mealplan import GroupMealPlan, GroupMealPlanRules
from .preferences import HouseholdPreferencesModel from .preferences import HouseholdPreferencesModel
@ -24,6 +25,7 @@ __all__ = [
"GroupMealPlanRules", "GroupMealPlanRules",
"Household", "Household",
"HouseholdPreferencesModel", "HouseholdPreferencesModel",
"HouseholdToRecipe",
"GroupRecipeAction", "GroupRecipeAction",
"ShoppingList", "ShoppingList",
"ShoppingListExtras", "ShoppingListExtras",

View file

@ -8,9 +8,13 @@ from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
from ..recipe.ingredient import households_to_ingredient_foods
from ..recipe.tool import households_to_tools
from .household_to_recipe import HouseholdToRecipe
if TYPE_CHECKING: if TYPE_CHECKING:
from ..group import Group from ..group import Group
from ..recipe import IngredientFoodModel, RecipeModel, Tool
from ..users import User from ..users import User
from . import ( from . import (
CookBook, CookBook,
@ -62,6 +66,18 @@ class Household(SqlAlchemyBase, BaseMixins):
"GroupEventNotifierModel", **COMMON_ARGS "GroupEventNotifierModel", **COMMON_ARGS
) )
made_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=HouseholdToRecipe.__tablename__, back_populates="made_by"
)
ingredient_foods_on_hand: Mapped[list["IngredientFoodModel"]] = orm.relationship(
"IngredientFoodModel",
secondary=households_to_ingredient_foods,
back_populates="households_with_ingredient_food",
)
tools_on_hand: Mapped[list["Tool"]] = orm.relationship(
"Tool", secondary=households_to_tools, back_populates="households_with_tool"
)
model_config = ConfigDict( model_config = ConfigDict(
exclude={ exclude={
"users", "users",
@ -72,6 +88,7 @@ class Household(SqlAlchemyBase, BaseMixins):
"invite_tokens", "invite_tokens",
"group_event_notifiers", "group_event_notifiers",
"group", "group",
"made_recipes",
} }
) )

View file

@ -0,0 +1,60 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Column, ForeignKey, UniqueConstraint, event
from sqlalchemy.engine.base import Connection
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm.session import Session
from mealie.db.models._model_utils.datetime import NaiveDateTime
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from ..recipe import RecipeModel
from .household import Household
class HouseholdToRecipe(SqlAlchemyBase, BaseMixins):
__tablename__ = "households_to_recipes"
__table_args__ = (UniqueConstraint("household_id", "recipe_id", name="household_id_recipe_id_key"),)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
household: Mapped["Household"] = relationship("Household", viewonly=True)
household_id = Column(GUID, ForeignKey("households.id"), index=True, primary_key=True)
recipe: Mapped["RecipeModel"] = relationship("RecipeModel", viewonly=True)
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True, primary_key=True)
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime)
@auto_init()
def __init__(self, **_) -> None:
pass
def update_recipe_last_made(session: Session, target: HouseholdToRecipe):
if not target.last_made:
return
from mealie.db.models.recipe.recipe import RecipeModel
recipe = session.query(RecipeModel).filter(RecipeModel.id == target.recipe_id).first()
if not recipe:
return
recipe.last_made = recipe.last_made or target.last_made
recipe.last_made = max(recipe.last_made, target.last_made)
@event.listens_for(HouseholdToRecipe, "after_insert")
@event.listens_for(HouseholdToRecipe, "after_update")
@event.listens_for(HouseholdToRecipe, "after_delete")
def update_recipe_rating_on_insert_or_delete(_, connection: Connection, target: HouseholdToRecipe):
session = Session(bind=connection)
update_recipe_last_made(session, target)
session.commit()

View file

@ -1,6 +1,7 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import sqlalchemy as sa import sqlalchemy as sa
from pydantic import ConfigDict
from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, event, orm from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, event, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -14,6 +15,16 @@ from .._model_utils.guid import GUID
if TYPE_CHECKING: if TYPE_CHECKING:
from ..group import Group from ..group import Group
from ..household import Household
households_to_ingredient_foods = sa.Table(
"households_to_ingredient_foods",
SqlAlchemyBase.metadata,
sa.Column("household_id", GUID, sa.ForeignKey("households.id"), index=True),
sa.Column("food_id", GUID, sa.ForeignKey("ingredient_foods.id"), index=True),
sa.UniqueConstraint("household_id", "food_id", name="household_id_food_id_key"),
)
class IngredientUnitModel(SqlAlchemyBase, BaseMixins): class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
@ -142,11 +153,13 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id])
households_with_ingredient_food: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=households_to_ingredient_foods, back_populates="ingredient_foods_on_hand"
)
name: Mapped[str | None] = mapped_column(String) name: Mapped[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String) plural_name: Mapped[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String) description: Mapped[str | None] = mapped_column(String)
on_hand: Mapped[bool] = mapped_column(Boolean)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship( ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="food" "RecipeIngredientModel", back_populates="food"
@ -165,20 +178,42 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
model_config = ConfigDict(
exclude={
"households_with_ingredient_food",
}
)
# Deprecated
on_hand: Mapped[bool] = mapped_column(Boolean, default=False)
@api_extras @api_extras
@auto_init() @auto_init()
def __init__( def __init__(
self, self,
session: Session, session: Session,
group_id: GUID,
name: str | None = None, name: str | None = None,
plural_name: str | None = None, plural_name: str | None = None,
households_with_ingredient_food: list[str] | None = None,
**_, **_,
) -> None: ) -> None:
from ..household import Household
if name is not None: if name is not None:
self.name_normalized = self.normalize(name) self.name_normalized = self.normalize(name)
if plural_name is not None: if plural_name is not None:
self.plural_name_normalized = self.normalize(plural_name) self.plural_name_normalized = self.normalize(plural_name)
if not households_with_ingredient_food:
self.households_with_ingredient_food = []
else:
self.households_with_ingredient_food = (
session.query(Household)
.filter(Household.group_id == group_id, Household.slug.in_(households_with_ingredient_food))
.all()
)
tableargs = [ tableargs = [
sa.UniqueConstraint("name", "group_id", name="ingredient_foods_name_group_id_key"), sa.UniqueConstraint("name", "group_id", name="ingredient_foods_name_group_id_key"),
sa.Index( sa.Index(

View file

@ -16,6 +16,7 @@ from mealie.db.models._model_utils.datetime import NaiveDateTime, get_utc_today
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from ..household.household_to_recipe import HouseholdToRecipe
from ..users.user_to_recipe import UserToRecipe from ..users.user_to_recipe import UserToRecipe
from .api_extras import ApiExtras, api_extras from .api_extras import ApiExtras, api_extras
from .assets import RecipeAsset from .assets import RecipeAsset
@ -136,7 +137,11 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
# Time Stamp Properties # Time Stamp Properties
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today) date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today)
date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime) date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime)
last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime) last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime)
made_by: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=HouseholdToRecipe.__tablename__, back_populates="made_recipes"
)
# Shopping List Refs # Shopping List Refs
shopping_list_refs: Mapped[list["ShoppingListRecipeReference"]] = orm.relationship( shopping_list_refs: Mapped[list["ShoppingListRecipeReference"]] = orm.relationship(

View file

@ -1,5 +1,6 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pydantic import ConfigDict
from slugify import slugify from slugify import slugify
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@ -10,8 +11,17 @@ from mealie.db.models._model_utils.guid import GUID
if TYPE_CHECKING: if TYPE_CHECKING:
from ..group import Group from ..group import Group
from ..household import Household
from . import RecipeModel from . import RecipeModel
households_to_tools = Table(
"households_to_tools",
SqlAlchemyBase.metadata,
Column("household_id", GUID, ForeignKey("households.id"), index=True),
Column("tool_id", GUID, ForeignKey("tools.id"), index=True),
UniqueConstraint("household_id", "tool_id", name="household_id_tool_id_key"),
)
recipes_to_tools = Table( recipes_to_tools = Table(
"recipes_to_tools", "recipes_to_tools",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
@ -40,11 +50,36 @@ class Tool(SqlAlchemyBase, BaseMixins):
name: Mapped[str] = mapped_column(String, index=True, nullable=False) name: Mapped[str] = mapped_column(String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(String, index=True, nullable=False) slug: Mapped[str] = mapped_column(String, index=True, nullable=False)
on_hand: Mapped[bool | None] = mapped_column(Boolean, default=False)
households_with_tool: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=households_to_tools, back_populates="tools_on_hand"
)
recipes: Mapped[list["RecipeModel"]] = orm.relationship( recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=recipes_to_tools, back_populates="tools" "RecipeModel", secondary=recipes_to_tools, back_populates="tools"
) )
model_config = ConfigDict(
exclude={
"households_with_tool",
}
)
# Deprecated
on_hand: Mapped[bool | None] = mapped_column(Boolean, default=False)
@auto_init() @auto_init()
def __init__(self, name, **_) -> None: def __init__(
self, session: orm.Session, group_id: GUID, name: str, households_with_tool: list[str] | None = None, **_
) -> None:
from ..household import Household
self.slug = slugify(name) self.slug = slugify(name)
if not households_with_tool:
self.households_with_tool = []
else:
self.households_with_tool = (
session.query(Household)
.filter(Household.group_id == group_id, Household.slug.in_(households_with_tool))
.all()
)

View file

@ -11,6 +11,7 @@ from mealie.db.models.group.preferences import GroupPreferencesModel
from mealie.db.models.household.cookbook import CookBook from mealie.db.models.household.cookbook import CookBook
from mealie.db.models.household.events import GroupEventNotifierModel from mealie.db.models.household.events import GroupEventNotifierModel
from mealie.db.models.household.household import Household from mealie.db.models.household.household import Household
from mealie.db.models.household.household_to_recipe import HouseholdToRecipe
from mealie.db.models.household.invite_tokens import GroupInviteToken from mealie.db.models.household.invite_tokens import GroupInviteToken
from mealie.db.models.household.mealplan import GroupMealPlan, GroupMealPlanRules from mealie.db.models.household.mealplan import GroupMealPlan, GroupMealPlanRules
from mealie.db.models.household.preferences import HouseholdPreferencesModel from mealie.db.models.household.preferences import HouseholdPreferencesModel
@ -37,7 +38,7 @@ from mealie.db.models.users.password_reset import PasswordResetModel
from mealie.db.models.users.user_to_recipe import UserToRecipe from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.repos.repository_cookbooks import RepositoryCookbooks from mealie.repos.repository_cookbooks import RepositoryCookbooks
from mealie.repos.repository_foods import RepositoryFood from mealie.repos.repository_foods import RepositoryFood
from mealie.repos.repository_household import RepositoryHousehold from mealie.repos.repository_household import RepositoryHousehold, RepositoryHouseholdRecipes
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
from mealie.repos.repository_units import RepositoryUnit from mealie.repos.repository_units import RepositoryUnit
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
@ -52,7 +53,7 @@ from mealie.schema.household.group_shopping_list import (
ShoppingListOut, ShoppingListOut,
ShoppingListRecipeRefOut, ShoppingListRecipeRefOut,
) )
from mealie.schema.household.household import HouseholdInDB from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeOut
from mealie.schema.household.household_preferences import ReadHouseholdPreferences from mealie.schema.household.household_preferences import ReadHouseholdPreferences
from mealie.schema.household.invite_token import ReadInviteToken from mealie.schema.household.invite_token import ReadInviteToken
from mealie.schema.household.webhook import ReadWebhook from mealie.schema.household.webhook import ReadWebhook
@ -231,6 +232,17 @@ class AllRepositories:
household_id=self.household_id, household_id=self.household_id,
) )
@cached_property
def household_recipes(self) -> RepositoryHouseholdRecipes:
return RepositoryHouseholdRecipes(
self.session,
PK_ID,
HouseholdToRecipe,
HouseholdRecipeOut,
group_id=self.group_id,
household_id=self.household_id,
)
@cached_property @cached_property
def cookbooks(self) -> RepositoryCookbooks: def cookbooks(self) -> RepositoryCookbooks:
return RepositoryCookbooks( return RepositoryCookbooks(

View file

@ -8,7 +8,7 @@ from typing import Any, Generic, TypeVar
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import UUID4, BaseModel from pydantic import UUID4, BaseModel
from sqlalchemy import Select, case, delete, func, nulls_first, nulls_last, select from sqlalchemy import ColumnElement, Select, case, delete, func, nulls_first, nulls_last, select
from sqlalchemy.orm import InstrumentedAttribute from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.sql import sqltypes from sqlalchemy.sql import sqltypes
@ -69,6 +69,10 @@ class RepositoryGeneric(Generic[Schema, Model]):
def household_id(self) -> UUID4 | None: def household_id(self) -> UUID4 | None:
return self._household_id return self._household_id
@property
def column_aliases(self) -> dict[str, ColumnElement]:
return {}
def _random_seed(self) -> str: def _random_seed(self) -> str:
return str(datetime.now(tz=UTC)) return str(datetime.now(tz=UTC))
@ -356,7 +360,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
if pagination.query_filter: if pagination.query_filter:
try: try:
query_filter_builder = QueryFilterBuilder(pagination.query_filter) query_filter_builder = QueryFilterBuilder(pagination.query_filter)
query = query_filter_builder.filter_query(query, model=self.model) query = query_filter_builder.filter_query(query, model=self.model, column_aliases=self.column_aliases)
except ValueError as e: except ValueError as e:
self.logger.error(e) self.logger.error(e)
@ -394,6 +398,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
order_dir: OrderDirection, order_dir: OrderDirection,
order_by_null: OrderByNullPosition | None, order_by_null: OrderByNullPosition | None,
) -> Select: ) -> Select:
order_attr = self.column_aliases.get(order_attr.key, order_attr)
# queries handle uppercase and lowercase differently, which is undesirable # queries handle uppercase and lowercase differently, which is undesirable
if isinstance(order_attr.type, sqltypes.String): if isinstance(order_attr.type, sqltypes.String):
order_attr = func.lower(order_attr) order_attr = func.lower(order_attr)

View file

@ -8,15 +8,20 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models.household.household import Household from mealie.db.models.household import Household, HouseholdToRecipe
from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tag import Tag from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.users import User from mealie.db.models.users.users import User
from mealie.repos.repository_generic import GroupRepositoryGeneric from mealie.repos.repository_generic import GroupRepositoryGeneric, HouseholdRepositoryGeneric
from mealie.schema.household.household import HouseholdCreate, HouseholdInDB, UpdateHousehold from mealie.schema.household import (
from mealie.schema.household.household_statistics import HouseholdStatistics HouseholdCreate,
HouseholdInDB,
HouseholdRecipeOut,
HouseholdStatistics,
UpdateHousehold,
)
class RepositoryHousehold(GroupRepositoryGeneric[HouseholdInDB, Household]): class RepositoryHousehold(GroupRepositoryGeneric[HouseholdInDB, Household]):
@ -101,3 +106,15 @@ class RepositoryHousehold(GroupRepositoryGeneric[HouseholdInDB, Household]):
total_tags=model_count(Tag, filter_household=False), total_tags=model_count(Tag, filter_household=False),
total_tools=model_count(Tool, filter_household=False), total_tools=model_count(Tool, filter_household=False),
) )
class RepositoryHouseholdRecipes(HouseholdRepositoryGeneric[HouseholdRecipeOut, HouseholdToRecipe]):
def get_by_recipe(self, recipe_id: UUID4) -> HouseholdRecipeOut | None:
if not self.household_id:
raise Exception("household_id not set")
stmt = select(HouseholdToRecipe).filter(
HouseholdToRecipe.household_id == self.household_id, HouseholdToRecipe.recipe_id == recipe_id
)
result = self.session.execute(stmt).scalars().one_or_none()
return None if result is None else self.schema.model_validate(result)

View file

@ -11,25 +11,22 @@ from slugify import slugify
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from mealie.db.models.household.household import Household from mealie.db.models.household import Household, HouseholdToRecipe
from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.ingredient import IngredientFoodModel, RecipeIngredientModel from mealie.db.models.recipe.ingredient import RecipeIngredientModel, households_to_ingredient_foods
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.settings import RecipeSettings from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.recipe.tag import Tag from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool, recipes_to_tools from mealie.db.models.recipe.tool import Tool, households_to_tools, recipes_to_tools
from mealie.db.models.users.user_to_recipe import UserToRecipe from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.db.models.users.users import User
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary
from mealie.schema.recipe.recipe_ingredient import IngredientFood from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
from mealie.schema.recipe.recipe_tool import RecipeToolOut from mealie.schema.recipe.recipe_tool import RecipeToolOut
from mealie.schema.response.pagination import ( from mealie.schema.response.pagination import PaginationQuery
OrderByNullPosition,
OrderDirection,
PaginationQuery,
)
from mealie.schema.response.query_filter import QueryFilterBuilder from mealie.schema.response.query_filter import QueryFilterBuilder
from ..db.models._model_base import SqlAlchemyBase from ..db.models._model_base import SqlAlchemyBase
@ -39,11 +36,58 @@ from .repository_generic import HouseholdRepositoryGeneric
class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]): class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
user_id: UUID4 | None = None user_id: UUID4 | None = None
@property
def column_aliases(self):
if not self.user_id:
return {}
return {
"last_made": self._get_last_made_col_alias(),
"rating": self._get_rating_col_alias(),
}
def by_user(self: Self, user_id: UUID4) -> Self: def by_user(self: Self, user_id: UUID4) -> Self:
"""Add a user_id to the repo, which will be used to handle recipe ratings""" """Add a user_id to the repo, which will be used to handle recipe ratings and other user-specific data"""
self.user_id = user_id self.user_id = user_id
return self return self
def _get_last_made_col_alias(self) -> sa.ColumnElement | None:
"""Computed last_made which uses `HouseholdToRecipe.last_made` for the user's household, otherwise None"""
user_household_subquery = sa.select(User.household_id).where(User.id == self.user_id).scalar_subquery()
return (
sa.select(HouseholdToRecipe.last_made)
.where(
HouseholdToRecipe.recipe_id == self.model.id,
HouseholdToRecipe.household_id == user_household_subquery,
)
.correlate(self.model)
.scalar_subquery()
)
def _get_rating_col_alias(self) -> sa.ColumnElement | None:
"""Computed rating which uses the user's rating if it exists, otherwise falling back to the recipe's rating"""
effective_rating = sa.case(
(
sa.exists().where(
UserToRecipe.recipe_id == self.model.id,
UserToRecipe.user_id == self.user_id,
UserToRecipe.rating != None, # noqa E711
UserToRecipe.rating > 0,
),
sa.select(sa.func.max(UserToRecipe.rating))
.where(UserToRecipe.recipe_id == self.model.id, UserToRecipe.user_id == self.user_id)
.correlate(self.model)
.scalar_subquery(),
),
else_=sa.case(
(self.model.rating == 0, None),
else_=self.model.rating,
),
)
return sa.cast(effective_rating, sa.Float)
def create(self, document: Recipe) -> Recipe: # type: ignore def create(self, document: Recipe) -> Recipe: # type: ignore
max_retries = 10 max_retries = 10
original_name: str = document.name # type: ignore original_name: str = document.name # type: ignore
@ -103,51 +147,6 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all() additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all()
return ids + additional_ids return ids + additional_ids
def add_order_attr_to_query(
self,
query: sa.Select,
order_attr: orm.InstrumentedAttribute,
order_dir: OrderDirection,
order_by_null: OrderByNullPosition | None,
) -> sa.Select:
"""Special handling for ordering recipes by rating"""
column_name = order_attr.key
if column_name != "rating" or not self.user_id:
return super().add_order_attr_to_query(query, order_attr, order_dir, order_by_null)
# calculate the effictive rating for the user by using the user's rating if it exists,
# falling back to the recipe's rating if it doesn't
effective_rating_column_name = "_effective_rating"
query = query.add_columns(
sa.case(
(
sa.exists().where(
UserToRecipe.recipe_id == self.model.id,
UserToRecipe.user_id == self.user_id,
UserToRecipe.rating is not None,
UserToRecipe.rating > 0,
),
sa.select(sa.func.max(UserToRecipe.rating))
.where(UserToRecipe.recipe_id == self.model.id, UserToRecipe.user_id == self.user_id)
.scalar_subquery(),
),
else_=sa.case((self.model.rating == 0, None), else_=self.model.rating),
).label(effective_rating_column_name)
)
order_attr = effective_rating_column_name
if order_dir is OrderDirection.asc:
order_attr = sa.asc(order_attr)
elif order_dir is OrderDirection.desc:
order_attr = sa.desc(order_attr)
if order_by_null is OrderByNullPosition.first:
order_attr = sa.nulls_first(order_attr)
else:
order_attr = sa.nulls_last(order_attr)
return query.order_by(order_attr)
def page_all( # type: ignore def page_all( # type: ignore
self, self,
pagination: PaginationQuery, pagination: PaginationQuery,
@ -320,33 +319,34 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
if not params.order_by: if not params.order_by:
params.order_by = "created_at" params.order_by = "created_at"
food_ids_with_on_hand = list(set(food_ids or [])) user_food_ids = list(set(food_ids or []))
tool_ids_with_on_hand = list(set(tool_ids or [])) user_tool_ids = list(set(tool_ids or []))
# preserve the original lists of ids before we add on_hand items # preserve the original lists of ids before we add on_hand items
user_food_ids = food_ids_with_on_hand.copy() food_ids_with_on_hand = user_food_ids.copy()
user_tool_ids = tool_ids_with_on_hand.copy() tool_ids_with_on_hand = user_tool_ids.copy()
if params.include_foods_on_hand: if params.include_foods_on_hand and self.user_id:
foods_on_hand_query = sa.select(IngredientFoodModel.id).filter( foods_on_hand_query = (
IngredientFoodModel.on_hand == True, # noqa: E712 - required for SQLAlchemy comparison sa.select(households_to_ingredient_foods.c.food_id)
sa.not_(IngredientFoodModel.id.in_(food_ids_with_on_hand)), .join(User, households_to_ingredient_foods.c.household_id == User.household_id)
.filter(
sa.not_(households_to_ingredient_foods.c.food_id.in_(food_ids_with_on_hand)),
User.id == self.user_id,
)
) )
if self.group_id:
foods_on_hand_query = foods_on_hand_query.filter(IngredientFoodModel.group_id == self.group_id)
foods_on_hand = self.session.execute(foods_on_hand_query).scalars().all() foods_on_hand = self.session.execute(foods_on_hand_query).scalars().all()
food_ids_with_on_hand.extend(foods_on_hand) food_ids_with_on_hand.extend(foods_on_hand)
if params.include_tools_on_hand:
tools_on_hand_query = sa.select(Tool.id).filter(
Tool.on_hand == True, # noqa: E712 - required for SQLAlchemy comparison
sa.not_(
Tool.id.in_(tool_ids_with_on_hand),
),
)
if self.group_id:
tools_on_hand_query = tools_on_hand_query.filter(Tool.group_id == self.group_id)
if params.include_tools_on_hand and self.user_id:
tools_on_hand_query = (
sa.select(households_to_tools.c.tool_id)
.join(User, households_to_tools.c.household_id == User.household_id)
.filter(
sa.not_(households_to_tools.c.tool_id.in_(tool_ids_with_on_hand)),
User.id == self.user_id,
)
)
tools_on_hand = self.session.execute(tools_on_hand_query).scalars().all() tools_on_hand = self.session.execute(tools_on_hand_query).scalars().all()
tool_ids_with_on_hand.extend(tools_on_hand) tool_ids_with_on_hand.extend(tools_on_hand)

View file

@ -5,7 +5,7 @@ from fastapi import Depends, HTTPException, status
from mealie.routes._base.base_controllers import BaseUserController from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller from mealie.routes._base.controller import controller
from mealie.routes._base.routers import UserAPIRouter from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.household.household import HouseholdInDB from mealie.schema.household import HouseholdInDB, HouseholdRecipeSummary
from mealie.schema.household.household_permissions import SetPermissions from mealie.schema.household.household_permissions import SetPermissions
from mealie.schema.household.household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences from mealie.schema.household.household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences
from mealie.schema.household.household_statistics import HouseholdStatistics from mealie.schema.household.household_statistics import HouseholdStatistics
@ -27,6 +27,15 @@ class HouseholdSelfServiceController(BaseUserController):
"""Returns the Household Data for the Current User""" """Returns the Household Data for the Current User"""
return self.household return self.household
@router.get("/self/recipes/{recipe_slug}", response_model=HouseholdRecipeSummary)
def get_household_recipe(self, recipe_slug: str):
"""Returns recipe data for the current household"""
response = self.service.get_household_recipe(recipe_slug)
if not response:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return response
@router.get("/members", response_model=PaginationBase[UserOut]) @router.get("/members", response_model=PaginationBase[UserOut])
def get_household_members(self, q: PaginationQuery = Depends()): def get_household_members(self, q: PaginationQuery = Depends()):
"""Returns all users belonging to the current household""" """Returns all users belonging to the current household"""

View file

@ -53,7 +53,9 @@ class GroupMealplanController(BaseCrudController):
""" """
rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(plan_date), entry_type.value) rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(plan_date), entry_type.value)
cross_household_recipes = get_repositories(self.session, group_id=self.group_id, household_id=None).recipes cross_household_recipes = get_repositories(
self.session, group_id=self.group_id, household_id=None
).recipes.by_user(self.user.id)
qf_string = " AND ".join([f"({rule.query_filter_string})" for rule in rules if rule.query_filter_string]) qf_string = " AND ".join([f"({rule.query_filter_string})" for rule in rules if rule.query_filter_string])
recipes_data = cross_household_recipes.page_all( recipes_data = cross_household_recipes.page_all(

View file

@ -46,6 +46,7 @@ class RecipeToolController(BaseUserController):
@router.put("/{item_id}", response_model=RecipeTool) @router.put("/{item_id}", response_model=RecipeTool)
def update_one(self, item_id: UUID4, data: RecipeToolCreate): def update_one(self, item_id: UUID4, data: RecipeToolCreate):
data = mapper.cast(data, RecipeToolSave, group_id=self.group_id)
return self.mixins.update_one(data, item_id) return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=RecipeTool) @router.delete("/{item_id}", response_model=RecipeTool)

View file

@ -24,6 +24,7 @@ from mealie.core.dependencies import (
get_temporary_zip_path, get_temporary_zip_path,
) )
from mealie.pkgs import cache from mealie.pkgs import cache
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import controller from mealie.routes._base import controller
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
@ -252,8 +253,9 @@ class RecipeController(BaseRecipeController):
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")
# We use "group_recipes" here so we can return all recipes regardless of household. The query filter can include # We use "group_recipes" here so we can return all recipes regardless of household. The query filter can
# a household_id to filter by household. We use the "by_user" so we can sort favorites correctly. # include a household_id to filter by household.
# We use "by_user" so we can sort favorites and other user-specific data correctly.
pagination_response = self.group_recipes.by_user(self.user.id).page_all( pagination_response = self.group_recipes.by_user(self.user.id).page_all(
pagination=q, pagination=q,
cookbook=cookbook_data, cookbook=cookbook_data,
@ -288,7 +290,11 @@ class RecipeController(BaseRecipeController):
foods: list[UUID4] | None = Query(None), foods: list[UUID4] | None = Query(None),
tools: list[UUID4] | None = Query(None), tools: list[UUID4] | None = Query(None),
) -> RecipeSuggestionResponse: ) -> RecipeSuggestionResponse:
recipes = self.group_recipes.find_suggested_recipes(q, foods, tools) group_recipes_by_user = get_repositories(
self.session, group_id=self.group_id, household_id=None
).recipes.by_user(self.user.id)
recipes = group_recipes_by_user.find_suggested_recipes(q, foods, tools)
response = RecipeSuggestionResponse(items=recipes) response = RecipeSuggestionResponse(items=recipes)
json_compatible_response = orjson.dumps(response.model_dump(by_alias=True)) json_compatible_response = orjson.dumps(response.model_dump(by_alias=True))

View file

@ -66,6 +66,7 @@ class IngredientFoodsController(BaseUserController):
@router.put("/{item_id}", response_model=IngredientFood) @router.put("/{item_id}", response_model=IngredientFood)
def update_one(self, item_id: UUID4, data: CreateIngredientFood): def update_one(self, item_id: UUID4, data: CreateIngredientFood):
data = mapper.cast(data, SaveIngredientFood, group_id=self.group_id)
return self.mixins.update_one(data, item_id) return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=IngredientFood) @router.delete("/{item_id}", response_model=IngredientFood)

View file

@ -46,6 +46,11 @@ from .household import (
HouseholdCreate, HouseholdCreate,
HouseholdInDB, HouseholdInDB,
HouseholdPagination, HouseholdPagination,
HouseholdRecipeBase,
HouseholdRecipeCreate,
HouseholdRecipeOut,
HouseholdRecipeSummary,
HouseholdRecipeUpdate,
HouseholdSave, HouseholdSave,
HouseholdSummary, HouseholdSummary,
HouseholdUserSummary, HouseholdUserSummary,
@ -91,6 +96,11 @@ __all__ = [
"HouseholdCreate", "HouseholdCreate",
"HouseholdInDB", "HouseholdInDB",
"HouseholdPagination", "HouseholdPagination",
"HouseholdRecipeBase",
"HouseholdRecipeCreate",
"HouseholdRecipeOut",
"HouseholdRecipeSummary",
"HouseholdRecipeUpdate",
"HouseholdSave", "HouseholdSave",
"HouseholdSummary", "HouseholdSummary",
"HouseholdUserSummary", "HouseholdUserSummary",

View file

@ -1,10 +1,11 @@
from datetime import datetime
from typing import Annotated from typing import Annotated
from pydantic import UUID4, ConfigDict, StringConstraints, field_validator from pydantic import UUID4, ConfigDict, StringConstraints, field_validator
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.household.household import Household from mealie.db.models.household import Household, HouseholdToRecipe
from mealie.db.models.users.users import User from mealie.db.models.users.users import User
from mealie.schema._mealie.mealie_model import MealieModel from mealie.schema._mealie.mealie_model import MealieModel
from mealie.schema.household.webhook import ReadWebhook from mealie.schema.household.webhook import ReadWebhook
@ -13,6 +14,34 @@ from mealie.schema.response.pagination import PaginationBase
from .household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences from .household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences
class HouseholdRecipeBase(MealieModel):
last_made: datetime | None = None
class HouseholdRecipeSummary(HouseholdRecipeBase):
recipe_id: UUID4
model_config = ConfigDict(from_attributes=True)
class HouseholdRecipeCreate(HouseholdRecipeBase):
household_id: UUID4
recipe_id: UUID4
class HouseholdRecipeUpdate(HouseholdRecipeBase): ...
class HouseholdRecipeOut(HouseholdRecipeCreate):
id: UUID4
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
joinedload(HouseholdToRecipe.household),
]
class HouseholdCreate(MealieModel): class HouseholdCreate(MealieModel):
group_id: UUID4 | None = None group_id: UUID4 | None = None
name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]

View file

@ -59,7 +59,17 @@ class RecipeCategoryPagination(PaginationBase):
class RecipeTool(RecipeTag): class RecipeTool(RecipeTag):
id: UUID4 id: UUID4
on_hand: bool = False households_with_tool: list[str] = []
@field_validator("households_with_tool", mode="before")
def convert_households_to_slugs(cls, v):
if not v:
return []
try:
return [household.slug for household in v]
except AttributeError:
return v
class RecipeToolPagination(PaginationBase): class RecipeToolPagination(PaginationBase):

View file

@ -7,7 +7,7 @@ from typing import ClassVar
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import UUID4, ConfigDict, Field, field_validator, model_validator from pydantic import UUID4, ConfigDict, Field, field_validator, model_validator
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.recipe import IngredientFoodModel from mealie.db.models.recipe import IngredientFoodModel
@ -37,7 +37,6 @@ class UnitFoodBase(MealieModel):
plural_name: str | None = None plural_name: str | None = None
description: str = "" description: str = ""
extras: dict | None = {} extras: dict | None = {}
on_hand: bool = False
@field_validator("id", mode="before") @field_validator("id", mode="before")
def convert_empty_id_to_none(cls, v): def convert_empty_id_to_none(cls, v):
@ -67,6 +66,7 @@ class IngredientFoodAlias(CreateIngredientFoodAlias):
class CreateIngredientFood(UnitFoodBase): class CreateIngredientFood(UnitFoodBase):
label_id: UUID4 | None = None label_id: UUID4 | None = None
aliases: list[CreateIngredientFoodAlias] = [] aliases: list[CreateIngredientFoodAlias] = []
households_with_ingredient_food: list[str] = []
class SaveIngredientFood(CreateIngredientFood): class SaveIngredientFood(CreateIngredientFood):
@ -91,10 +91,24 @@ class IngredientFood(CreateIngredientFood):
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
return [ return [
selectinload(IngredientFoodModel.households_with_ingredient_food),
joinedload(IngredientFoodModel.extras), joinedload(IngredientFoodModel.extras),
joinedload(IngredientFoodModel.label), joinedload(IngredientFoodModel.label),
] ]
@field_validator("households_with_ingredient_food", mode="before")
def convert_households_to_slugs(cls, v):
if not v:
return []
try:
return [household.slug for household in v]
except AttributeError:
return v
def is_on_hand(self, household_slug: str) -> bool:
return household_slug in self.households_with_tool
class IngredientFoodPagination(PaginationBase): class IngredientFoodPagination(PaginationBase):
items: list[IngredientFood] items: list[IngredientFood]

View file

@ -1,15 +1,14 @@
from pydantic import UUID4, ConfigDict from pydantic import UUID4, ConfigDict, field_validator
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.recipe import RecipeModel, Tool
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from ...db.models.recipe import RecipeModel, Tool
class RecipeToolCreate(MealieModel): class RecipeToolCreate(MealieModel):
name: str name: str
on_hand: bool = False households_with_tool: list[str] = []
class RecipeToolSave(RecipeToolCreate): class RecipeToolSave(RecipeToolCreate):
@ -19,8 +18,28 @@ class RecipeToolSave(RecipeToolCreate):
class RecipeToolOut(RecipeToolCreate): class RecipeToolOut(RecipeToolCreate):
id: UUID4 id: UUID4
slug: str slug: str
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@field_validator("households_with_tool", mode="before")
def convert_households_to_slugs(cls, v):
if not v:
return []
try:
return [household.slug for household in v]
except AttributeError:
return v
def is_on_hand(self, household_slug: str) -> bool:
return household_slug in self.households_with_tool
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
selectinload(Tool.households_with_tool),
]
class RecipeToolResponse(RecipeToolOut): class RecipeToolResponse(RecipeToolOut):
recipes: list["RecipeSummary"] = [] recipes: list["RecipeSummary"] = []
@ -29,6 +48,7 @@ class RecipeToolResponse(RecipeToolOut):
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
return [ return [
selectinload(Tool.households_with_tool),
selectinload(Tool.recipes).joinedload(RecipeModel.recipe_category), selectinload(Tool.recipes).joinedload(RecipeModel.recipe_category),
selectinload(Tool.recipes).joinedload(RecipeModel.tags), selectinload(Tool.recipes).joinedload(RecipeModel.tags),
selectinload(Tool.recipes).joinedload(RecipeModel.tools), selectinload(Tool.recipes).joinedload(RecipeModel.tools),

View file

@ -6,10 +6,10 @@ from enum import Enum
from typing import Any, TypeVar, cast from typing import Any, TypeVar, cast
from uuid import UUID from uuid import UUID
import sqlalchemy as sa
from dateutil import parser as date_parser from dateutil import parser as date_parser
from dateutil.parser import ParserError from dateutil.parser import ParserError
from humps import decamelize from humps import decamelize
from sqlalchemy import ColumnElement, Select, and_, inspect, or_
from sqlalchemy.ext.associationproxy import AssociationProxyInstance from sqlalchemy.ext.associationproxy import AssociationProxyInstance
from sqlalchemy.orm import InstrumentedAttribute, Mapper from sqlalchemy.orm import InstrumentedAttribute, Mapper
from sqlalchemy.sql import sqltypes from sqlalchemy.sql import sqltypes
@ -251,17 +251,19 @@ class QueryFilterBuilder:
return f"<<{joined}>>" return f"<<{joined}>>"
@classmethod @classmethod
def _consolidate_group(cls, group: list[ColumnElement], logical_operators: deque[LogicalOperator]) -> ColumnElement: def _consolidate_group(
consolidated_group_builder: ColumnElement | None = None cls, group: list[sa.ColumnElement], logical_operators: deque[LogicalOperator]
) -> sa.ColumnElement:
consolidated_group_builder: sa.ColumnElement | None = None
for i, element in enumerate(reversed(group)): for i, element in enumerate(reversed(group)):
if not i: if not i:
consolidated_group_builder = element consolidated_group_builder = element
else: else:
operator = logical_operators.pop() operator = logical_operators.pop()
if operator is LogicalOperator.AND: if operator is LogicalOperator.AND:
consolidated_group_builder = and_(consolidated_group_builder, element) consolidated_group_builder = sa.and_(consolidated_group_builder, element)
elif operator is LogicalOperator.OR: elif operator is LogicalOperator.OR:
consolidated_group_builder = or_(consolidated_group_builder, element) consolidated_group_builder = sa.or_(consolidated_group_builder, element)
else: else:
raise ValueError(f"invalid logical operator {operator}") raise ValueError(f"invalid logical operator {operator}")
@ -270,8 +272,8 @@ class QueryFilterBuilder:
@classmethod @classmethod
def get_model_and_model_attr_from_attr_string( def get_model_and_model_attr_from_attr_string(
cls, attr_string: str, model: type[Model], *, query: Select | None = None cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, Select | None]: ) -> tuple[SqlAlchemyBase, InstrumentedAttribute, sa.Select | None]:
""" """
Take an attribute string and traverse a database model and its relationships to get the desired Take an attribute string and traverse a database model and its relationships to get the desired
model and model attribute. Optionally provide a query to apply the necessary table joins. model and model attribute. Optionally provide a query to apply the necessary table joins.
@ -287,7 +289,7 @@ class QueryFilterBuilder:
mapper: Mapper mapper: Mapper
model_attr: InstrumentedAttribute | None = None model_attr: InstrumentedAttribute | None = None
attribute_chain = attr_string.split(".") attribute_chain = decamelize(attr_string).split(".")
if not attribute_chain: if not attribute_chain:
raise ValueError("invalid query string: attribute name cannot be empty") raise ValueError("invalid query string: attribute name cannot be empty")
@ -306,7 +308,7 @@ class QueryFilterBuilder:
if query is not None: if query is not None:
query = query.join(model_attr, isouter=True) query = query.join(model_attr, isouter=True)
mapper = inspect(current_model) mapper = sa.inspect(current_model)
relationship = mapper.relationships[proxied_attribute_link] relationship = mapper.relationships[proxied_attribute_link]
current_model = relationship.mapper.class_ current_model = relationship.mapper.class_
model_attr = getattr(current_model, next_attribute_link) model_attr = getattr(current_model, next_attribute_link)
@ -318,7 +320,7 @@ class QueryFilterBuilder:
if query is not None: if query is not None:
query = query.join(model_attr, isouter=True) query = query.join(model_attr, isouter=True)
mapper = inspect(current_model) mapper = sa.inspect(current_model)
relationship = mapper.relationships[attribute_link] relationship = mapper.relationships[attribute_link]
current_model = relationship.mapper.class_ current_model = relationship.mapper.class_
@ -330,7 +332,56 @@ class QueryFilterBuilder:
return current_model, model_attr, query return current_model, model_attr, query
def filter_query(self, query: Select, model: type[Model]) -> Select: @staticmethod
def _get_filter_element(
component: QueryFilterBuilderComponent, model, model_attr, model_attr_type
) -> sa.ColumnElement:
# Keywords
if component.relationship is RelationalKeyword.IS:
element = model_attr.is_(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.IS_NOT:
element = model_attr.is_not(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.IN:
element = model_attr.in_(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.NOT_IN:
element = model_attr.not_in(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.CONTAINS_ALL:
primary_model_attr: InstrumentedAttribute = getattr(model, component.attribute_name.split(".")[0])
element = sa.and_()
for v in component.validate(model_attr_type):
element = sa.and_(element, primary_model_attr.any(model_attr == v))
elif component.relationship is RelationalKeyword.LIKE:
element = model_attr.like(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.NOT_LIKE:
element = model_attr.not_like(component.validate(model_attr_type))
# Operators
elif component.relationship is RelationalOperator.EQ:
element = model_attr == component.validate(model_attr_type)
elif component.relationship is RelationalOperator.NOTEQ:
element = model_attr != component.validate(model_attr_type)
elif component.relationship is RelationalOperator.GT:
element = model_attr > component.validate(model_attr_type)
elif component.relationship is RelationalOperator.LT:
element = model_attr < component.validate(model_attr_type)
elif component.relationship is RelationalOperator.GTE:
element = model_attr >= component.validate(model_attr_type)
elif component.relationship is RelationalOperator.LTE:
element = model_attr <= component.validate(model_attr_type)
else:
raise ValueError(f"invalid relationship {component.relationship}")
return element
def filter_query(
self, query: sa.Select, model: type[Model], column_aliases: dict[str, sa.ColumnElement] | None = None
) -> sa.Select:
"""
Filters a query based on the parsed filter string.
If you need to filter on a custom column expression (e.g. a computed property), you can supply column aliases
"""
column_aliases = column_aliases or {}
# join tables and build model chain # join tables and build model chain
attr_model_map: dict[int, Any] = {} attr_model_map: dict[int, Any] = {}
model_attr: InstrumentedAttribute model_attr: InstrumentedAttribute
@ -344,8 +395,8 @@ class QueryFilterBuilder:
attr_model_map[i] = nested_model attr_model_map[i] = nested_model
# build query filter # build query filter
partial_group: list[ColumnElement] = [] partial_group: list[sa.ColumnElement] = []
partial_group_stack: deque[list[ColumnElement]] = deque() partial_group_stack: deque[list[sa.ColumnElement]] = deque()
logical_operator_stack: deque[LogicalOperator] = deque() logical_operator_stack: deque[LogicalOperator] = deque()
for i, component in enumerate(self.filter_components): for i, component in enumerate(self.filter_components):
if component == self.l_group_sep: if component == self.l_group_sep:
@ -365,43 +416,13 @@ class QueryFilterBuilder:
else: else:
component = cast(QueryFilterBuilderComponent, component) component = cast(QueryFilterBuilderComponent, component)
model_attr = getattr(attr_model_map[i], component.attribute_name.split(".")[-1]) base_attribute_name = component.attribute_name.split(".")[-1]
model_attr = getattr(attr_model_map[i], base_attribute_name)
# Keywords if (column_alias := column_aliases.get(base_attribute_name)) is not None:
if component.relationship is RelationalKeyword.IS: model_attr = column_alias
element = model_attr.is_(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.IS_NOT:
element = model_attr.is_not(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.IN:
element = model_attr.in_(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.NOT_IN:
element = model_attr.not_in(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.CONTAINS_ALL:
primary_model_attr: InstrumentedAttribute = getattr(model, component.attribute_name.split(".")[0])
element = and_()
for v in component.validate(model_attr.type):
element = and_(element, primary_model_attr.any(model_attr == v))
elif component.relationship is RelationalKeyword.LIKE:
element = model_attr.like(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.NOT_LIKE:
element = model_attr.not_like(component.validate(model_attr.type))
# Operators
elif component.relationship is RelationalOperator.EQ:
element = model_attr == component.validate(model_attr.type)
elif component.relationship is RelationalOperator.NOTEQ:
element = model_attr != component.validate(model_attr.type)
elif component.relationship is RelationalOperator.GT:
element = model_attr > component.validate(model_attr.type)
elif component.relationship is RelationalOperator.LT:
element = model_attr < component.validate(model_attr.type)
elif component.relationship is RelationalOperator.GTE:
element = model_attr >= component.validate(model_attr.type)
elif component.relationship is RelationalOperator.LTE:
element = model_attr <= component.validate(model_attr.type)
else:
raise ValueError(f"invalid relationship {component.relationship}")
element = self._get_filter_element(component, model, model_attr, model_attr.type)
partial_group.append(element) partial_group.append(element)
# combine the completed groups into one filter # combine the completed groups into one filter

View file

@ -1,10 +1,15 @@
from uuid import UUID
from pydantic import UUID4 from pydantic import UUID4
from mealie.core import exceptions
from mealie.repos.all_repositories import get_repositories from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.schema.household.household import HouseholdCreate from mealie.schema.household import HouseholdCreate, HouseholdRecipeSummary
from mealie.schema.household.household import HouseholdRecipeCreate, HouseholdRecipeUpdate
from mealie.schema.household.household_preferences import CreateHouseholdPreferences, SaveHouseholdPreferences from mealie.schema.household.household_preferences import CreateHouseholdPreferences, SaveHouseholdPreferences
from mealie.schema.household.household_statistics import HouseholdStatistics from mealie.schema.household.household_statistics import HouseholdStatistics
from mealie.schema.recipe.recipe import Recipe
from mealie.services._base_service import BaseService from mealie.services._base_service import BaseService
@ -15,6 +20,19 @@ class HouseholdService(BaseService):
self.repos = repos self.repos = repos
super().__init__() super().__init__()
def _get_recipe(self, recipe_slug: str | UUID) -> Recipe | None:
key = "id"
if not isinstance(recipe_slug, UUID):
try:
UUID(recipe_slug)
except ValueError:
key = "slug"
cross_household_recipes = get_repositories(
self.repos.session, group_id=self.group_id, household_id=None
).recipes
return cross_household_recipes.get_one(recipe_slug, key)
@staticmethod @staticmethod
def create_household( def create_household(
repos: AllRepositories, h_base: HouseholdCreate, prefs: CreateHouseholdPreferences | None = None repos: AllRepositories, h_base: HouseholdCreate, prefs: CreateHouseholdPreferences | None = None
@ -48,3 +66,34 @@ class HouseholdService(BaseService):
household_id = household_id or self.household_id household_id = household_id or self.household_id
return self.repos.households.statistics(group_id, household_id) return self.repos.households.statistics(group_id, household_id)
def get_household_recipe(self, recipe_slug: str) -> HouseholdRecipeSummary | None:
"""Returns recipe data for the current household"""
recipe = self._get_recipe(recipe_slug)
if not recipe:
return None
household_recipe_out = self.repos.household_recipes.get_by_recipe(recipe.id)
if household_recipe_out:
return household_recipe_out.cast(HouseholdRecipeSummary)
else:
return HouseholdRecipeSummary(recipe_id=recipe.id)
def set_household_recipe(self, recipe_slug: str | UUID, data: HouseholdRecipeUpdate) -> HouseholdRecipeSummary:
"""Sets the household's recipe data"""
recipe = self._get_recipe(recipe_slug)
if not recipe:
raise exceptions.NoEntryFound("Recipe not found.")
existing_household_recipe = self.repos.household_recipes.get_by_recipe(recipe.id)
if existing_household_recipe:
updated_data = existing_household_recipe.cast(HouseholdRecipeUpdate, **data.model_dump())
household_recipe_out = self.repos.household_recipes.patch(existing_household_recipe.id, updated_data)
else:
create_data = HouseholdRecipeCreate(
household_id=self.household_id, recipe_id=recipe.id, **data.model_dump()
)
household_recipe_out = self.repos.household_recipes.create(create_data)
return household_recipe_out.cast(HouseholdRecipeSummary)

View file

@ -19,7 +19,7 @@ from mealie.pkgs import cache
from mealie.repos.all_repositories import get_repositories from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_generic import RepositoryGeneric from mealie.repos.repository_generic import RepositoryGeneric
from mealie.schema.household.household import HouseholdInDB from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeUpdate
from mealie.schema.openai.recipe import OpenAIRecipe from mealie.schema.openai.recipe import OpenAIRecipe
from mealie.schema.recipe.recipe import CreateRecipe, Recipe from mealie.schema.recipe.recipe import CreateRecipe, Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
@ -30,6 +30,7 @@ from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreat
from mealie.schema.recipe.request_helpers import RecipeDuplicate from mealie.schema.recipe.request_helpers import RecipeDuplicate
from mealie.schema.user.user import PrivateUser, UserRatingCreate from mealie.schema.user.user import PrivateUser, UserRatingCreate
from mealie.services._base_service import BaseService from mealie.services._base_service import BaseService
from mealie.services.household_services.household_service import HouseholdService
from mealie.services.openai import OpenAIDataInjection, OpenAILocalImage, OpenAIService from mealie.services.openai import OpenAIDataInjection, OpenAILocalImage, OpenAIService
from mealie.services.recipe.recipe_data_service import RecipeDataService from mealie.services.recipe.recipe_data_service import RecipeDataService
from mealie.services.scraper import cleaner from mealie.services.scraper import cleaner
@ -173,6 +174,7 @@ class RecipeService(RecipeServiceBase):
data.settings = RecipeSettings() data.settings = RecipeSettings()
rating_input = data.rating rating_input = data.rating
data.last_made = None
new_recipe = self.repos.recipes.create(data) new_recipe = self.repos.recipes.create(data)
# convert rating into user rating # convert rating into user rating
@ -342,6 +344,7 @@ class RecipeService(RecipeServiceBase):
if old_recipe.recipe_ingredient is None if old_recipe.recipe_ingredient is None
else list(map(copy_recipe_ingredient, old_recipe.recipe_ingredient)) else list(map(copy_recipe_ingredient, old_recipe.recipe_ingredient))
) )
new_recipe.last_made = None
new_recipe = self._recipe_creation_factory(new_name, additional_attrs=new_recipe.model_dump()) new_recipe = self._recipe_creation_factory(new_name, additional_attrs=new_recipe.model_dump())
@ -413,8 +416,11 @@ class RecipeService(RecipeServiceBase):
def update_last_made(self, slug_or_id: str | UUID, timestamp: datetime) -> Recipe: def update_last_made(self, slug_or_id: str | UUID, timestamp: datetime) -> Recipe:
# we bypass the pre update check since any user can update a recipe's last made date, even if it's locked, # we bypass the pre update check since any user can update a recipe's last made date, even if it's locked,
# or if the user belongs to a different household # or if the user belongs to a different household
recipe = self.get_one(slug_or_id)
return self.group_recipes.patch(recipe.slug, {"last_made": timestamp}) household_service = HouseholdService(self.user.group_id, self.user.household_id, self.repos)
household_service.set_household_recipe(slug_or_id, HouseholdRecipeUpdate(last_made=timestamp))
return self.get_one(slug_or_id)
def delete_one(self, slug_or_id: str | UUID) -> Recipe: def delete_one(self, slug_or_id: str | UUID) -> Recipe:
recipe = self.get_one(slug_or_id) recipe = self.get_one(slug_or_id)

View file

@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from mealie.db.db_setup import session_context from mealie.db.db_setup import session_context
from mealie.repos.all_repositories import get_repositories from mealie.repos.all_repositories import get_repositories
from mealie.schema.household.household import HouseholdRecipeUpdate
from mealie.schema.meal_plan.new_meal import PlanEntryType from mealie.schema.meal_plan.new_meal import PlanEntryType
from mealie.schema.recipe.recipe import RecipeSummary from mealie.schema.recipe.recipe import RecipeSummary
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
@ -18,12 +19,14 @@ from mealie.services.event_bus_service.event_types import (
EventRecipeTimelineEventData, EventRecipeTimelineEventData,
EventTypes, EventTypes,
) )
from mealie.services.household_services.household_service import HouseholdService
def _create_mealplan_timeline_events_for_household( def _create_mealplan_timeline_events_for_household(
event_time: datetime, session: Session, group_id: UUID4, household_id: UUID4 event_time: datetime, session: Session, group_id: UUID4, household_id: UUID4
) -> None: ) -> None:
repos = get_repositories(session, group_id=group_id, household_id=household_id) repos = get_repositories(session, group_id=group_id, household_id=household_id)
household_service = HouseholdService(group_id, household_id, repos)
event_bus_service = EventBusService(session=session) event_bus_service = EventBusService(session=session)
timeline_events_to_create: list[RecipeTimelineEventCreate] = [] timeline_events_to_create: list[RecipeTimelineEventCreate] = []
@ -64,7 +67,8 @@ def _create_mealplan_timeline_events_for_household(
continue continue
# bump up the last made date # bump up the last made date
last_made = mealplan.recipe.last_made household_to_recipe = household_service.get_household_recipe(mealplan.recipe.slug)
last_made = household_to_recipe.last_made if household_to_recipe else None
if (not last_made or last_made.date() < event_time.date()) and mealplan.recipe_id not in recipes_to_update: if (not last_made or last_made.date() < event_time.date()) and mealplan.recipe_id not in recipes_to_update:
recipes_to_update[mealplan.recipe_id] = mealplan.recipe recipes_to_update[mealplan.recipe_id] = mealplan.recipe
@ -99,6 +103,7 @@ def _create_mealplan_timeline_events_for_household(
) )
for recipe in recipes_to_update.values(): for recipe in recipes_to_update.values():
household_service.set_household_recipe(recipe.slug, HouseholdRecipeUpdate(last_made=event_time))
repos.recipes.patch(recipe.slug, {"last_made": event_time}) repos.recipes.patch(recipe.slug, {"last_made": event_time})
event_bus_service.dispatch( event_bus_service.dispatch(
integration_id=DEFAULT_INTEGRATION_ID, integration_id=DEFAULT_INTEGRATION_ID,

View file

@ -138,6 +138,9 @@ exclude = [
# Assume Python 3.12. # Assume Python 3.12.
target-version = "py312" target-version = "py312"
[tool.ruff.lint.isort]
known-third-party = ["alembic"]
[tool.ruff.lint] [tool.ruff.lint]
# Enable Pyflakes `E` and `F` codes by default. # Enable Pyflakes `E` and `F` codes by default.
ignore = ["F403", "TID252", "B008"] ignore = ["F403", "TID252", "B008"]
@ -158,8 +161,7 @@ select = [
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"__init__.py" = ["E402", "E501"] "__init__.py" = ["E402", "E501"]
"mealie/alembic/versions/2022*" = ["E501", "I001"] "mealie/alembic/versions/*" = ["E501", "I001"]
"mealie/alembic/versions/2023*" = ["E501", "I001"]
"dev/scripts/all_recipes_stress_test.py" = ["E501"] "dev/scripts/all_recipes_stress_test.py" = ["E501"]
"ldap_provider.py" = ["UP032"] "ldap_provider.py" = ["UP032"]
"tests/conftest.py" = ["E402"] "tests/conftest.py" = ["E402"]

View file

@ -1,6 +1,13 @@
from datetime import datetime, timezone
from uuid import UUID
from dateutil.parser import parse as parse_dt
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from mealie.db.models.household import HouseholdToRecipe
from mealie.schema.recipe.recipe import Recipe
from tests.utils import api_routes from tests.utils import api_routes
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser from tests.utils.fixture_schemas import TestUser
@ -18,3 +25,61 @@ def test_get_household_members(api_client: TestClient, user_tuple: list[TestUser
assert str(usr_1.user_id) in all_ids assert str(usr_1.user_id) in all_ids
assert str(usr_2.user_id) in all_ids assert str(usr_2.user_id) in all_ids
assert str(h2_user.user_id) not in all_ids assert str(h2_user.user_id) not in all_ids
def test_get_household_recipe_default(api_client: TestClient, unique_user: TestUser):
recipe = unique_user.repos.recipes.create(
Recipe(
user_id=unique_user.user_id,
group_id=UUID(unique_user.group_id),
name=random_string(),
)
)
response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=unique_user.token)
assert response.status_code == 200
assert response.json()["recipeId"] == str(recipe.id)
assert response.json()["lastMade"] is None
def test_get_household_recipe(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
dt_now = datetime.now(tz=timezone.utc)
recipe = unique_user.repos.recipes.create(
Recipe(
user_id=unique_user.user_id,
group_id=UUID(unique_user.group_id),
name=random_string(),
)
)
session = unique_user.repos.session
session.add(
HouseholdToRecipe(
session=session,
household_id=UUID(unique_user.household_id),
recipe_id=recipe.id,
last_made=dt_now,
)
)
session.commit()
response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=unique_user.token)
assert response.status_code == 200
data = response.json()
assert data["recipeId"] == str(recipe.id)
assert data["lastMade"]
assert parse_dt(data["lastMade"]) == dt_now
response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=h2_user.token)
assert response.status_code == 200
h2_data = response.json()
assert h2_data["recipeId"] == str(recipe.id)
assert h2_data["lastMade"] is None
def test_get_household_recipe_invalid_recipe(api_client: TestClient, unique_user: TestUser):
response = api_client.get(
api_routes.households_self_recipes_recipe_slug(random_string()), headers=unique_user.token
)
assert response.status_code == 404

View file

@ -1,6 +1,7 @@
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
import pytest import pytest
from dateutil.parser import parse as parse_dt
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from mealie.schema.cookbook.cookbook import SaveCookBook from mealie.schema.cookbook.cookbook import SaveCookBook
@ -233,28 +234,50 @@ def test_user_can_update_last_made_on_other_household(
assert response.status_code == 201 assert response.status_code == 201
h2_recipe = h2_user.repos.recipes.get_one(response.json()) h2_recipe = h2_user.repos.recipes.get_one(response.json())
assert h2_recipe and h2_recipe.id assert h2_recipe and h2_recipe.id
h2_recipe_id = h2_recipe.id
h2_recipe_slug = h2_recipe.slug h2_recipe_slug = h2_recipe.slug
response = api_client.get(api_routes.recipes_slug(h2_recipe_slug), headers=unique_user.token) dt_1 = datetime.now(tz=UTC)
assert response.status_code == 200 dt_2 = dt_1 + timedelta(days=2)
recipe = response.json()
assert recipe["id"] == str(h2_recipe_id)
old_last_made = recipe["lastMade"]
now = datetime.now(UTC).isoformat().replace("+00:00", "Z") # set last made for unique_user and make sure it only updates globally and for unique_user
response = api_client.patch( response = api_client.patch(
api_routes.recipes_slug_last_made(h2_recipe_slug), json={"timestamp": now}, headers=unique_user.token api_routes.recipes_slug_last_made(h2_recipe.slug),
json={"timestamp": dt_2.isoformat()},
headers=unique_user.token,
) )
assert response.status_code == 200 assert response.status_code == 200
response = api_client.get(api_routes.households_self_recipes_recipe_slug(h2_recipe_slug), headers=unique_user.token)
# confirm the last made date was updated
response = api_client.get(api_routes.recipes_slug(h2_recipe_slug), headers=unique_user.token)
assert response.status_code == 200 assert response.status_code == 200
recipe = response.json() assert (last_made_json := response.json()["lastMade"])
assert recipe["id"] == str(h2_recipe_id) assert parse_dt(last_made_json) == dt_2
new_last_made = recipe["lastMade"]
assert new_last_made == now != old_last_made response = api_client.get(api_routes.households_self_recipes_recipe_slug(h2_recipe_slug), headers=h2_user.token)
assert response.status_code == 200
assert response.json()["lastMade"] is None
recipe = h2_user.repos.recipes.get_one(h2_recipe_slug)
assert recipe
assert recipe.last_made == dt_2
# set last made for h2_user and make sure it only updates globally and for h2_user
response = api_client.patch(
api_routes.recipes_slug_last_made(h2_recipe.slug), json={"timestamp": dt_1.isoformat()}, headers=h2_user.token
)
assert response.status_code == 200
response = api_client.get(api_routes.households_self_recipes_recipe_slug(h2_recipe_slug), headers=h2_user.token)
assert response.status_code == 200
assert (last_made_json := response.json()["lastMade"])
assert parse_dt(last_made_json) == dt_1
response = api_client.get(api_routes.households_self_recipes_recipe_slug(h2_recipe_slug), headers=unique_user.token)
assert response.status_code == 200
assert (last_made_json := response.json()["lastMade"])
assert parse_dt(last_made_json) == dt_2
# this shouldn't have updated since dt_2 is newer than dt_1
recipe = h2_user.repos.recipes.get_one(h2_recipe_slug)
assert recipe
assert recipe.last_made == dt_2
def test_cookbook_recipes_includes_all_households(api_client: TestClient, unique_user: TestUser, h2_user: TestUser): def test_cookbook_recipes_includes_all_households(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):

View file

@ -14,14 +14,32 @@ from tests.utils.fixture_schemas import TestUser
def create_food(user: TestUser, on_hand: bool = False): def create_food(user: TestUser, on_hand: bool = False):
if on_hand:
household = user.repos.households.get_by_slug_or_id(user.household_id)
assert household
households = [household.slug]
else:
households = []
return user.repos.ingredient_foods.create( return user.repos.ingredient_foods.create(
SaveIngredientFood(id=uuid4(), name=random_string(), group_id=user.group_id, on_hand=on_hand) SaveIngredientFood(
id=uuid4(), name=random_string(), group_id=user.group_id, households_with_ingredient_food=households
)
) )
def create_tool(user: TestUser, on_hand: bool = False): def create_tool(user: TestUser, on_hand: bool = False):
if on_hand:
household = user.repos.households.get_by_slug_or_id(user.household_id)
assert household
households = [household.slug]
else:
households = []
return user.repos.tools.create( return user.repos.tools.create(
RecipeToolSave(id=uuid4(), name=random_string(), group_id=user.group_id, on_hand=on_hand) RecipeToolSave(
id=uuid4(), name=random_string(), group_id=user.group_id, on_hand=on_hand, households_with_tool=households
)
) )
@ -568,7 +586,7 @@ def test_include_cross_household_recipes(api_client: TestClient, unique_user: Te
try: try:
response = api_client.get( response = api_client.get(
api_routes.recipes_suggestions, api_routes.recipes_suggestions,
params={"maxMissingFoods": 0, "foods": [str(known_food.id)], "includeCrossHousehold": True}, params={"maxMissingFoods": 0, "foods": [str(known_food.id)]},
headers=h2_user.token, headers=h2_user.token,
) )
response.raise_for_status() response.raise_for_status()
@ -579,3 +597,61 @@ def test_include_cross_household_recipes(api_client: TestClient, unique_user: Te
finally: finally:
unique_user.repos.recipes.delete(recipe.slug) unique_user.repos.recipes.delete(recipe.slug)
h2_user.repos.recipes.delete(other_recipe.slug) h2_user.repos.recipes.delete(other_recipe.slug)
def test_respect_cross_household_on_hand_food(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
on_hand_food = create_food(unique_user, on_hand=True) # only on-hand for unique_user
other_food = create_food(unique_user)
recipe = create_recipe(unique_user, foods=[on_hand_food, other_food])
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 0, "foods": [str(other_food.id)]},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["recipe"]["id"] == str(recipe.id)
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 0, "foods": [str(other_food.id)]},
headers=h2_user.token,
)
response.raise_for_status()
data = response.json()
assert len(data["items"]) == 0
finally:
unique_user.repos.recipes.delete(recipe.slug)
def test_respect_cross_household_on_hand_tool(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
on_hand_tool = create_tool(unique_user, on_hand=True) # only on-hand for unique_user
other_tool = create_tool(unique_user)
recipe = create_recipe(unique_user, tools=[on_hand_tool, other_tool])
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingTools": 0, "tools": [str(other_tool.id)]},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["recipe"]["id"] == str(recipe.id)
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingTools": 0, "tools": [str(other_tool.id)]},
headers=h2_user.token,
)
response.raise_for_status()
data = response.json()
assert len(data["items"]) == 0
finally:
unique_user.repos.recipes.delete(recipe.slug)

View file

@ -33,6 +33,7 @@ from mealie.schema.response.pagination import (
OrderDirection, OrderDirection,
PaginationQuery, PaginationQuery,
) )
from mealie.schema.user.user import UserRatingUpdate
from mealie.services.seeder.seeder_service import SeederService from mealie.services.seeder.seeder_service import SeederService
from tests.utils import api_routes from tests.utils import api_routes
from tests.utils.factories import random_int, random_string from tests.utils.factories import random_int, random_string
@ -1320,3 +1321,105 @@ def test_pagination_filter_nested(api_client: TestClient, user_tuple: list[TestU
recipe_id = event_data["recipeId"] recipe_id = event_data["recipeId"]
assert recipe_id in recipe_ids[i] assert recipe_id in recipe_ids[i]
assert recipe_id not in recipe_ids[(i + 1) % len(user_tuple)] assert recipe_id not in recipe_ids[(i + 1) % len(user_tuple)]
def test_pagination_filter_by_custom_last_made(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
recipe_1, recipe_2 = (
unique_user.repos.recipes.create(
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
)
for _ in range(2)
)
dt_1 = "2023-02-25"
dt_2 = "2023-03-25"
r = api_client.patch(
api_routes.recipes_slug_last_made(recipe_1.slug),
json={"timestamp": dt_1},
headers=unique_user.token,
)
assert r.status_code == 200
r = api_client.patch(
api_routes.recipes_slug_last_made(recipe_2.slug),
json={"timestamp": dt_2},
headers=unique_user.token,
)
assert r.status_code == 200
r = api_client.patch(
api_routes.recipes_slug_last_made(recipe_1.slug),
json={"timestamp": dt_2},
headers=h2_user.token,
)
assert r.status_code == 200
r = api_client.patch(
api_routes.recipes_slug_last_made(recipe_2.slug),
json={"timestamp": dt_1},
headers=h2_user.token,
)
assert r.status_code == 200
params = {"page": 1, "perPage": -1, "queryFilter": "lastMade > 2023-03-01"}
# User 1 should fetch Recipe 2
response = api_client.get(api_routes.recipes, params=params, headers=unique_user.token)
assert response.status_code == 200
recipes_data = response.json()["items"]
assert len(recipes_data) == 1
assert recipes_data[0]["id"] == str(recipe_2.id)
# User 2 should fetch Recipe 1
response = api_client.get(api_routes.recipes, params=params, headers=h2_user.token)
assert response.status_code == 200
recipes_data = response.json()["items"]
assert len(recipes_data) == 1
assert recipes_data[0]["id"] == str(recipe_1.id)
def test_pagination_filter_by_custom_rating(api_client: TestClient, user_tuple: list[TestUser]):
user_1, user_2 = user_tuple
recipe_1, recipe_2 = (
user_1.repos.recipes.create(Recipe(user_id=user_1.user_id, group_id=user_1.group_id, name=random_string()))
for _ in range(2)
)
r = api_client.post(
api_routes.users_id_ratings_slug(user_1.user_id, recipe_1.slug),
json=UserRatingUpdate(rating=5).model_dump(),
headers=user_1.token,
)
assert r.status_code == 200
r = api_client.post(
api_routes.users_id_ratings_slug(user_1.user_id, recipe_2.slug),
json=UserRatingUpdate(rating=1).model_dump(),
headers=user_1.token,
)
assert r.status_code == 200
r = api_client.post(
api_routes.users_id_ratings_slug(user_2.user_id, recipe_1.slug),
json=UserRatingUpdate(rating=1).model_dump(),
headers=user_2.token,
)
assert r.status_code == 200
r = api_client.post(
api_routes.users_id_ratings_slug(user_2.user_id, recipe_2.slug),
json=UserRatingUpdate(rating=5).model_dump(),
headers=user_2.token,
)
assert r.status_code == 200
qf = "rating > 3"
params = {"page": 1, "perPage": -1, "queryFilter": qf}
# User 1 should fetch Recipe 1
response = api_client.get(api_routes.recipes, params=params, headers=user_1.token)
assert response.status_code == 200
recipes_data = response.json()["items"]
assert len(recipes_data) == 1
assert recipes_data[0]["id"] == str(recipe_1.id)
# User 2 should fetch Recipe 2
response = api_client.get(api_routes.recipes, params=params, headers=user_2.token)
assert response.status_code == 200
recipes_data = response.json()["items"]
assert len(recipes_data) == 1
assert recipes_data[0]["id"] == str(recipe_2.id)

View file

@ -1,4 +1,4 @@
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from typing import cast from typing import cast
from uuid import UUID from uuid import UUID
@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from mealie.repos.all_repositories import get_repositories from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_recipes import RepositoryRecipes from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.schema.household.household import HouseholdCreate from mealie.schema.household.household import HouseholdCreate, HouseholdRecipeCreate
from mealie.schema.recipe import RecipeIngredient, SaveIngredientFood from mealie.schema.recipe import RecipeIngredient, SaveIngredientFood
from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary
from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave
@ -706,6 +706,63 @@ def test_random_order_recipe_search(
assert not all(i == random_ordered[0] for i in random_ordered) assert not all(i == random_ordered[0] for i in random_ordered)
def test_order_by_last_made(unique_user: TestUser, h2_user: TestUser):
dt_1 = datetime.now(UTC)
dt_2 = dt_1 + timedelta(days=2)
recipe_1, recipe_2 = (
unique_user.repos.recipes.create(
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string())
)
for _ in range(2)
)
# In ascending order:
# unique_user: recipe_1, recipe_2
# h2_user: recipe_2, recipe_1
unique_user.repos.household_recipes.create(
HouseholdRecipeCreate(recipe_id=recipe_1.id, household_id=unique_user.household_id, last_made=dt_1)
)
h2_user.repos.household_recipes.create(
HouseholdRecipeCreate(recipe_id=recipe_1.id, household_id=h2_user.household_id, last_made=dt_2)
)
unique_user.repos.household_recipes.create(
HouseholdRecipeCreate(recipe_id=recipe_2.id, household_id=unique_user.household_id, last_made=dt_2)
)
h2_user.repos.household_recipes.create(
HouseholdRecipeCreate(recipe_id=recipe_2.id, household_id=h2_user.household_id, last_made=dt_1)
)
h1_recipes = get_repositories(
unique_user.repos.session, group_id=unique_user.group_id, household_id=None
).recipes.by_user(unique_user.user_id)
h2_recipes = get_repositories(h2_user.repos.session, group_id=h2_user.group_id, household_id=None).recipes.by_user(
h2_user.user_id
)
h1_query = h1_recipes.page_all(
PaginationQuery(
page=1,
per_page=-1,
order_by="last_made",
order_direction=OrderDirection.asc,
query_filter=f"id IN [{recipe_1.id}, {recipe_2.id}]",
)
)
assert [item.id for item in h1_query.items] == [recipe_1.id, recipe_2.id]
h2_query = h2_recipes.page_all(
PaginationQuery(
page=1,
per_page=-1,
order_by="lastMade",
order_direction=OrderDirection.asc,
query_filter=f"id IN [{recipe_1.id}, {recipe_2.id}]",
)
)
assert [item.id for item in h2_query.items] == [recipe_2.id, recipe_1.id]
def test_order_by_rating(user_tuple: tuple[TestUser, TestUser]): def test_order_by_rating(user_tuple: tuple[TestUser, TestUser]):
user_1, user_2 = user_tuple user_1, user_2 = user_tuple
database = user_1.repos database = user_1.repos

View file

@ -1,9 +1,8 @@
import filecmp import filecmp
import statistics import statistics
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any
import pytest
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import tests.data as test_data import tests.data as test_data
@ -12,11 +11,14 @@ from mealie.db.db_setup import session_context
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from mealie.db.models.group import Group from mealie.db.models.group import Group
from mealie.db.models.household.cookbook import CookBook from mealie.db.models.household.cookbook import CookBook
from mealie.db.models.household.household import Household
from mealie.db.models.household.household_to_recipe import HouseholdToRecipe
from mealie.db.models.household.mealplan import GroupMealPlanRules from mealie.db.models.household.mealplan import GroupMealPlanRules
from mealie.db.models.household.shopping_list import ShoppingList from mealie.db.models.household.shopping_list import ShoppingList
from mealie.db.models.labels import MultiPurposeLabel from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.user_to_recipe import UserToRecipe from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.db.models.users.users import User from mealie.db.models.users.users import User
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
@ -74,30 +76,148 @@ def test_database_restore():
assert snapshop_1[s1].sort(key=dict_sorter) == snapshop_2[s2].sort(key=dict_sorter) assert snapshop_1[s1].sort(key=dict_sorter) == snapshop_2[s2].sort(key=dict_sorter)
@pytest.mark.parametrize( def _5ab195a474eb_add_normalized_search_properties(session: Session):
"backup_path", recipes = session.query(RecipeModel).all()
[
test_data.backup_version_44e8d670719d_1, for recipe in recipes:
test_data.backup_version_44e8d670719d_2, if recipe.name:
test_data.backup_version_44e8d670719d_3, assert recipe.name_normalized
test_data.backup_version_44e8d670719d_4, if recipe.description:
test_data.backup_version_ba1e4a6cfe99_1, assert recipe.description_normalized
test_data.backup_version_bcfdad6b7355_1,
test_data.backup_version_09aba125b57a_1, for ingredient in recipe.recipe_ingredient:
test_data.backup_version_86054b40fd06_1, if ingredient.note:
], assert ingredient.note_normalized
ids=[ if ingredient.original_text:
"44e8d670719d_1: add extras to shopping lists, list items, and ingredient foods", assert ingredient.original_text_normalized
"44e8d670719d_2: add extras to shopping lists, list items, and ingredient foods",
"44e8d670719d_3: add extras to shopping lists, list items, and ingredient foods",
"44e8d670719d_4: add extras to shopping lists, list items, and ingredient foods", def _b04a08da2108_added_shopping_list_label_settings(session: Session):
"bcfdad6b7355_1: remove tool name and slug unique contraints", shopping_lists = session.query(ShoppingList).all()
"ba1e4a6cfe99_1: added plural names and alias tables for foods and units", labels = session.query(MultiPurposeLabel).all()
"09aba125b57a_1: add OIDC auth method (Safari-mangled ZIP structure)",
"86054b40fd06_1: added query_filter_string to cookbook and mealplan", for shopping_list in shopping_lists:
], group_labels = [label for label in labels if label.group_id == shopping_list.group_id]
) assert len(shopping_list.label_settings) == len(group_labels)
def test_database_restore_data(backup_path: Path): for label_setting, label in zip(
sorted(shopping_list.label_settings, key=lambda x: x.label.id),
sorted(group_labels, key=lambda x: x.id),
strict=True,
):
assert label_setting.label == label
def _04ac51cbe9a4_added_group_slug(session: Session):
groups = session.query(Group).all()
for group in groups:
assert group.slug
def _0341b154f79a_added_normalized_unit_and_food_names(session: Session):
foods = session.query(IngredientFoodModel).all()
units = session.query(IngredientUnitModel).all()
for food in foods:
if food.name:
assert food.name_normalized
for unit in units:
assert unit.name_normalized
if unit.abbreviation:
assert unit.abbreviation_normalized
def _d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings(session: Session):
recipes = session.query(RecipeModel).all()
users_by_group_id: dict[GUID, list[User]] = {}
for recipe in recipes:
users = users_by_group_id.get(recipe.group_id)
if users is None:
users = session.query(User).filter(User.group_id == recipe.group_id).all()
users_by_group_id[recipe.group_id] = users
user_to_recipes = session.query(UserToRecipe).filter(UserToRecipe.recipe_id == recipe.id).all()
user_ratings = [x.rating for x in user_to_recipes if x.rating]
assert recipe.rating == (statistics.mean(user_ratings) if user_ratings else None)
def _86054b40fd06_added_query_filter_string_to_cookbook_and_mealplan(session: Session):
cookbooks = session.query(CookBook).all()
mealplan_rules = session.query(GroupMealPlanRules).all()
for cookbook in cookbooks:
parts = []
if cookbook.categories:
relop = "CONTAINS ALL" if cookbook.require_all_categories else "IN"
vals = ",".join([f'"{cat.id}"' for cat in cookbook.categories])
parts.append(f"recipe_category.id {relop} [{vals}]")
if cookbook.tags:
relop = "CONTAINS ALL" if cookbook.require_all_tags else "IN"
vals = ",".join([f'"{tag.id}"' for tag in cookbook.tags])
parts.append(f"tags.id {relop} [{vals}]")
if cookbook.tools:
relop = "CONTAINS ALL" if cookbook.require_all_tools else "IN"
vals = ",".join([f'"{tool.id}"' for tool in cookbook.tools])
parts.append(f"tools.id {relop} [{vals}]")
expected_query_filter_string = " AND ".join(parts)
assert cookbook.query_filter_string == expected_query_filter_string
for rule in mealplan_rules:
parts = []
if rule.categories:
vals = ",".join([f'"{cat.id}"' for cat in rule.categories])
parts.append(f"recipe_category.id CONTAINS ALL [{vals}]")
if rule.tags:
vals = ",".join([f'"{tag.id}"' for tag in rule.tags])
parts.append(f"tags.id CONTAINS ALL [{vals}]")
if rule.households:
vals = ",".join([f'"{household.id}"' for household in rule.households])
parts.append(f"household_id IN [{vals}]")
expected_query_filter_string = " AND ".join(parts)
assert rule.query_filter_string == expected_query_filter_string
def _b9e516e2d3b3_add_household_to_recipe_last_made_household_to_foods_and_tools(session: Session):
groups = session.query(Group).all()
for group in groups:
households = session.query(Household).filter(Household.group_id == group.id).all()
household_ids = {household.id for household in households}
recipes = session.query(RecipeModel).filter(RecipeModel.group_id == group.id).all()
for recipe in recipes:
for household in households:
household_to_recipe = (
session.query(HouseholdToRecipe)
.filter(HouseholdToRecipe.recipe_id == recipe.id, HouseholdToRecipe.household_id == household.id)
.one_or_none()
)
if recipe.last_made:
assert household_to_recipe
assert household_to_recipe.last_made == recipe.last_made
else:
assert not household_to_recipe
foods = session.query(IngredientFoodModel).filter(IngredientFoodModel.group_id == group.id).all()
for food in foods:
if food.on_hand:
assert {hh.id for hh in food.households_with_ingredient_food} == household_ids
else:
assert not food.households_with_ingredient_food
tools = session.query(Tool).filter(Tool.group_id == group.id).all()
for tool in tools:
if tool.on_hand:
assert {hh.id for hh in tool.households_with_tool} == household_ids
else:
assert not tool.households_with_tool
def test_database_restore_data():
""" """
This tests real user backups to make sure the data is restored correctly. The data has been anonymized, but This tests real user backups to make sure the data is restored correctly. The data has been anonymized, but
relationships and data types should be preserved. relationships and data types should be preserved.
@ -106,114 +226,45 @@ def test_database_restore_data(backup_path: Path):
If a new migration is added that does any sort of data manipulation, this test should be updated. If a new migration is added that does any sort of data manipulation, this test should be updated.
""" """
backup_paths = [
test_data.backup_version_44e8d670719d_1,
test_data.backup_version_44e8d670719d_2,
test_data.backup_version_44e8d670719d_3,
test_data.backup_version_44e8d670719d_4,
test_data.backup_version_ba1e4a6cfe99_1,
test_data.backup_version_bcfdad6b7355_1,
test_data.backup_version_09aba125b57a_1,
test_data.backup_version_86054b40fd06_1,
]
migration_funcs = [
_5ab195a474eb_add_normalized_search_properties,
_b04a08da2108_added_shopping_list_label_settings,
_04ac51cbe9a4_added_group_slug,
_0341b154f79a_added_normalized_unit_and_food_names,
_d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings,
_86054b40fd06_added_query_filter_string_to_cookbook_and_mealplan,
_b9e516e2d3b3_add_household_to_recipe_last_made_household_to_foods_and_tools,
]
settings = get_app_settings() settings = get_app_settings()
backup_v2 = BackupV2(settings.DB_URL) backup_v2 = BackupV2(settings.DB_URL)
# create a backup of the existing data so we can restore it later
original_data_backup = backup_v2.backup() original_data_backup = backup_v2.backup()
try: try:
assert backup_path.exists() for backup_path in backup_paths:
backup_v2.restore(backup_path) assert backup_path.exists()
backup_v2.restore(backup_path)
# make sure migrations populated data successfully with session_context() as session:
with session_context() as session: for migration_func in migration_funcs:
session = cast(Session, session) try:
migration_func(session)
groups = session.query(Group).all() except Exception as e:
recipes = session.query(RecipeModel).all() session.rollback()
shopping_lists = session.query(ShoppingList).all() raise Exception(
labels = session.query(MultiPurposeLabel).all() f'Migration "{migration_func.__name__}" failed on backup "{backup_path}"'
) from e
foods = session.query(IngredientFoodModel).all()
units = session.query(IngredientUnitModel).all()
cookbooks = session.query(CookBook).all()
mealplan_rules = session.query(GroupMealPlanRules).all()
# 2023-02-14-20.45.41_5ab195a474eb_add_normalized_search_properties
for recipe in recipes:
if recipe.name:
assert recipe.name_normalized
if recipe.description:
assert recipe.description_normalized
for ingredient in recipe.recipe_ingredient:
if ingredient.note:
assert ingredient.note_normalized
if ingredient.original_text:
assert ingredient.original_text_normalized
# 2023-02-21-22.03.19_b04a08da2108_added_shopping_list_label_settings
for shopping_list in shopping_lists:
group_labels = [label for label in labels if label.group_id == shopping_list.group_id]
assert len(shopping_list.label_settings) == len(group_labels)
for label_setting, label in zip(
sorted(shopping_list.label_settings, key=lambda x: x.label.id),
sorted(group_labels, key=lambda x: x.id),
strict=True,
):
assert label_setting.label == label
# 2023-08-06-21.00.34_04ac51cbe9a4_added_group_slug
for group in groups:
assert group.slug
# 2023-09-01-14.55.42_0341b154f79a_added_normalized_unit_and_food_names
for food in foods:
if food.name:
assert food.name_normalized
for unit in units:
assert unit.name_normalized
if unit.abbreviation:
assert unit.abbreviation_normalized
# 2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings
users_by_group_id: dict[GUID, list[User]] = {}
for recipe in recipes:
users = users_by_group_id.get(recipe.group_id)
if users is None:
users = session.query(User).filter(User.group_id == recipe.group_id).all()
users_by_group_id[recipe.group_id] = users
user_to_recipes = session.query(UserToRecipe).filter(UserToRecipe.recipe_id == recipe.id).all()
user_ratings = [x.rating for x in user_to_recipes if x.rating]
assert recipe.rating == (statistics.mean(user_ratings) if user_ratings else None)
# 2024-10-08-21.17.31_86054b40fd06_added_query_filter_string_to_cookbook_and_mealplan
for cookbook in cookbooks:
parts = []
if cookbook.categories:
relop = "CONTAINS ALL" if cookbook.require_all_categories else "IN"
vals = ",".join([f'"{cat.id}"' for cat in cookbook.categories])
parts.append(f"recipe_category.id {relop} [{vals}]")
if cookbook.tags:
relop = "CONTAINS ALL" if cookbook.require_all_tags else "IN"
vals = ",".join([f'"{tag.id}"' for tag in cookbook.tags])
parts.append(f"tags.id {relop} [{vals}]")
if cookbook.tools:
relop = "CONTAINS ALL" if cookbook.require_all_tools else "IN"
vals = ",".join([f'"{tool.id}"' for tool in cookbook.tools])
parts.append(f"tools.id {relop} [{vals}]")
expected_query_filter_string = " AND ".join(parts)
assert cookbook.query_filter_string == expected_query_filter_string
for rule in mealplan_rules:
parts = []
if rule.categories:
vals = ",".join([f'"{cat.id}"' for cat in rule.categories])
parts.append(f"recipe_category.id CONTAINS ALL [{vals}]")
if rule.tags:
vals = ",".join([f'"{tag.id}"' for tag in rule.tags])
parts.append(f"tags.id CONTAINS ALL [{vals}]")
if rule.households:
vals = ",".join([f'"{household.id}"' for household in rule.households])
parts.append(f"household_id IN [{vals}]")
expected_query_filter_string = " AND ".join(parts)
assert rule.query_filter_string == expected_query_filter_string
finally: finally:
backup_v2.restore(original_data_backup) backup_v2.restore(original_data_backup)

View file

@ -1,12 +1,13 @@
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from dateutil.parser import parse as parse_dt
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from pydantic import UUID4 from pydantic import UUID4
from mealie.schema.household.household import HouseholdRecipeSummary
from mealie.schema.meal_plan.new_meal import CreatePlanEntry from mealie.schema.meal_plan.new_meal import CreatePlanEntry
from mealie.schema.recipe.recipe import RecipeSummary from mealie.schema.recipe.recipe import RecipeLastMade, RecipeSummary
from mealie.services.scheduler.tasks.create_timeline_events import create_mealplan_timeline_events from mealie.services.scheduler.tasks.create_timeline_events import create_mealplan_timeline_events
from tests import utils
from tests.utils import api_routes from tests.utils import api_routes
from tests.utils.factories import random_int, random_string from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser from tests.utils.fixture_schemas import TestUser
@ -17,7 +18,7 @@ def test_no_mealplans():
create_mealplan_timeline_events() create_mealplan_timeline_events()
def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser): def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
recipe_name = random_string(length=25) recipe_name = random_string(length=25)
response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token) response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token)
assert response.status_code == 201 assert response.status_code == 201
@ -65,7 +66,7 @@ def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser):
response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token)
new_recipe_data: dict = response.json() new_recipe_data: dict = response.json()
recipe = RecipeSummary.model_validate(new_recipe_data) recipe = RecipeSummary.model_validate(new_recipe_data)
assert recipe.last_made.date() == datetime.now(UTC).date() # type: ignore assert recipe.last_made and recipe.last_made.date() == datetime.now(UTC).date()
# make sure nothing else was updated # make sure nothing else was updated
for data in [original_recipe_data, new_recipe_data]: for data in [original_recipe_data, new_recipe_data]:
@ -85,6 +86,19 @@ def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser):
assert original_recipe_data == new_recipe_data assert original_recipe_data == new_recipe_data
# make sure the user's last made date was updated
response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe_name), headers=unique_user.token)
assert response.status_code == 200
response_json = response.json()
assert response_json["lastMade"]
assert parse_dt(response_json["lastMade"]).date() == datetime.now(UTC).date()
# make sure the other user's last made date was not updated
response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe_name), headers=h2_user.token)
assert response.status_code == 200
response_json = response.json()
assert response_json["lastMade"] is None
def test_new_mealplan_event_duplicates(api_client: TestClient, unique_user: TestUser): def test_new_mealplan_event_duplicates(api_client: TestClient, unique_user: TestUser):
recipe_name = random_string(length=25) recipe_name = random_string(length=25)
@ -191,7 +205,7 @@ def test_new_mealplan_events_with_multiple_recipes(api_client: TestClient, uniqu
assert len(response_json["items"]) == target_count assert len(response_json["items"]) == target_count
def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser): def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
recipe_name = random_string(length=25) recipe_name = random_string(length=25)
response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token) response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token)
assert response.status_code == 201 assert response.status_code == 201
@ -201,12 +215,22 @@ def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser
recipe_id = str(recipe.id) recipe_id = str(recipe.id)
future_dt = datetime.now(UTC) + timedelta(days=random_int(1, 10)) future_dt = datetime.now(UTC) + timedelta(days=random_int(1, 10))
recipe.last_made = future_dt response = api_client.patch(
response = api_client.put( api_routes.recipes_slug_last_made(recipe.slug),
api_routes.recipes_slug(recipe.slug), json=utils.jsonify(recipe), headers=unique_user.token data=RecipeLastMade(timestamp=future_dt).model_dump_json(),
headers=unique_user.token,
) )
assert response.status_code == 200 assert response.status_code == 200
# verify the last made date was updated only on unique_user
response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=unique_user.token)
household_recipe = HouseholdRecipeSummary.model_validate(response.json())
assert household_recipe.last_made == future_dt
response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=h2_user.token)
household_recipe = HouseholdRecipeSummary.model_validate(response.json())
assert household_recipe.last_made is None
new_plan = CreatePlanEntry(date=datetime.now(UTC).date(), entry_type="dinner", recipe_id=recipe_id).model_dump( new_plan = CreatePlanEntry(date=datetime.now(UTC).date(), entry_type="dinner", recipe_id=recipe_id).model_dump(
by_alias=True by_alias=True
) )
@ -216,9 +240,14 @@ def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser
response = api_client.post(api_routes.households_mealplans, json=new_plan, headers=unique_user.token) response = api_client.post(api_routes.households_mealplans, json=new_plan, headers=unique_user.token)
assert response.status_code == 201 assert response.status_code == 201
# run the task and make sure the recipe's last made date was not updated # run the task and make sure the recipe's last made date was not updated for either user
create_mealplan_timeline_events() create_mealplan_timeline_events()
response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=unique_user.token)
recipe = RecipeSummary.model_validate(response.json()) assert response.status_code == 200
assert recipe.last_made == future_dt household_recipe = HouseholdRecipeSummary.model_validate(response.json())
assert household_recipe.last_made == future_dt
response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=h2_user.token)
household_recipe = HouseholdRecipeSummary.model_validate(response.json())
assert household_recipe.last_made is None

View file

@ -370,6 +370,11 @@ def households_recipe_actions_item_id_trigger_recipe_slug(item_id, recipe_slug):
return f"{prefix}/households/recipe-actions/{item_id}/trigger/{recipe_slug}" return f"{prefix}/households/recipe-actions/{item_id}/trigger/{recipe_slug}"
def households_self_recipes_recipe_slug(recipe_slug):
"""`/api/households/self/recipes/{recipe_slug}`"""
return f"{prefix}/households/self/recipes/{recipe_slug}"
def households_shopping_items_item_id(item_id): def households_shopping_items_item_id(item_id):
"""`/api/households/shopping/items/{item_id}`""" """`/api/households/shopping/items/{item_id}`"""
return f"{prefix}/households/shopping/items/{item_id}" return f"{prefix}/households/shopping/items/{item_id}"