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,
});
const userHousehold = computed(() => {
return $auth.user?.householdSlug || "";
});
const shoppingListChoices = computed(() => {
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 householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
return {
checked: !ing.food?.onHand,
checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing,
disableAmount: recipe.settings?.disableAmount || false,
}
@ -276,7 +281,8 @@ export default defineComponent({
}
// 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);
return sections;
}

View file

@ -96,7 +96,12 @@
<v-icon left>
{{ $globals.icons.calendar }}
</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>
</div>
<div class="d-flex justify-center flex-wrap mt-1">
@ -110,7 +115,7 @@
</template>
<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 { VForm } from "~/types/vuetify";
import { useUserApi } from "~/composables/api";
@ -119,10 +124,6 @@ import { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
export default defineComponent({
props: {
value: {
type: String,
default: null,
},
recipe: {
type: Object as () => Recipe,
required: true,
@ -146,6 +147,20 @@ export default defineComponent({
const newTimelineEventImagePreviewUrl = 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(
() => madeThisDialog.value,
() => {
@ -195,11 +210,9 @@ export default defineComponent({
const newEvent = eventResponse.data;
// 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);
// update recipe in parent so the user can see it
context.emit("input", newTimelineEvent.value.timestamp);
}
// update the image, if provided
@ -234,6 +247,8 @@ export default defineComponent({
newTimelineEventImage,
newTimelineEventImagePreviewUrl,
newTimelineEventTimestamp,
lastMade,
lastMadeReady,
createTimelineEvent,
clearImage,
uploadImage,

View file

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

View file

@ -10,7 +10,7 @@
<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-checkbox
v-model="recipe.tools[index].onHand"
v-model="recipeTools[index].onHand"
hide-details
class="pt-0 my-auto py-auto"
color="secondary"
@ -26,14 +26,18 @@
</template>
<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 { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { useToolStore } from "~/composables/store";
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";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineComponent({
components: {
RecipeIngredients,
@ -59,9 +63,31 @@ export default defineComponent({
const { user } = usePageUser();
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) {
if (user.id && toolStore) {
toolStore.actions.updateOne(props.recipe.tools[index]);
if (user.id && user.householdSlug && toolStore) {
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 {
console.log("no user, skipping server update");
}
@ -69,6 +95,7 @@ export default defineComponent({
return {
toolStore,
recipeTools,
isEditMode,
updateTool,
};

View file

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

View file

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

View file

@ -13,7 +13,6 @@ export const useFoodData = function () {
name: "",
description: "",
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 { usePublicExploreApi, useUserApi } from "~/composables/api";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
const store: Ref<RecipeTool[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export const useToolData = function () {
return useData<RecipeTool>({
return useData<RecipeToolWithOnHand>({
id: "",
name: "",
slug: "",
onHand: false,
householdsWithTool: [],
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,8 +5,8 @@
:icon="$globals.icons.potSteam"
:items="tools"
item-type="tools"
@delete="actions.deleteOne"
@update="actions.updateOne"
@delete="deleteOne"
@update="updateOne"
>
<template #title> {{ $t("tool.tools") }} </template>
</RecipeOrganizerPage>
@ -14,9 +14,14 @@
</template>
<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 { useToolStore } from "~/composables/store";
import { RecipeTool } from "~/lib/api/types/recipe";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineComponent({
components: {
@ -24,13 +29,42 @@ export default defineComponent({
},
middleware: ["auth", "group-only"],
setup() {
const { $auth } = useContext();
const toolStore = useToolStore();
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 {
dialog,
tools: toolStore.store,
actions: toolStore.actions,
tools,
deleteOne,
updateOne,
};
},
head() {

View file

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

View file

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

View file

@ -101,14 +101,18 @@
</template>
<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 { 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({
setup() {
const { i18n } = useContext();
const { $auth, i18n } = useContext();
const tableConfig = {
hideColumns: true,
canExport: true,
@ -138,27 +142,38 @@ export default defineComponent({
bulkDeleteDialog: false,
});
const userHousehold = computed(() => $auth.user?.householdSlug || "");
const toolData = useToolData();
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() {
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)
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();
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;
editTarget.value = item;
}
@ -167,17 +182,29 @@ export default defineComponent({
if (!editTarget.value) {
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);
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;
deleteTarget.value = item;
}
@ -191,10 +218,10 @@ export default defineComponent({
}
// ============================================================
// Bulk Delete Tag
// Bulk Delete Tool
const bulkDeleteTarget = ref<RecipeTool[]>([]);
function bulkDeleteEventHandler(selection: RecipeTool[]) {
const bulkDeleteTarget = ref<RecipeToolWithOnHand[]>([]);
function bulkDeleteEventHandler(selection: RecipeToolWithOnHand[]) {
bulkDeleteTarget.value = selection;
state.bulkDeleteDialog = true;
}
@ -210,7 +237,7 @@ export default defineComponent({
state,
tableConfig,
tableHeaders,
tools: toolStore.store,
tools,
validators,
// 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 .events import GroupEventNotifierModel, GroupEventNotifierOptionsModel
from .household import Household
from .household_to_recipe import HouseholdToRecipe
from .invite_tokens import GroupInviteToken
from .mealplan import GroupMealPlan, GroupMealPlanRules
from .preferences import HouseholdPreferencesModel
@ -24,6 +25,7 @@ __all__ = [
"GroupMealPlanRules",
"Household",
"HouseholdPreferencesModel",
"HouseholdToRecipe",
"GroupRecipeAction",
"ShoppingList",
"ShoppingListExtras",

View file

@ -8,9 +8,13 @@ from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
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:
from ..group import Group
from ..recipe import IngredientFoodModel, RecipeModel, Tool
from ..users import User
from . import (
CookBook,
@ -62,6 +66,18 @@ class Household(SqlAlchemyBase, BaseMixins):
"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(
exclude={
"users",
@ -72,6 +88,7 @@ class Household(SqlAlchemyBase, BaseMixins):
"invite_tokens",
"group_event_notifiers",
"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
import sqlalchemy as sa
from pydantic import ConfigDict
from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, event, orm
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm.session import Session
@ -14,6 +15,16 @@ from .._model_utils.guid import GUID
if TYPE_CHECKING:
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):
@ -142,11 +153,13 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
# ID Relationships
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])
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)
plural_name: 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(
"RecipeIngredientModel", back_populates="food"
@ -165,20 +178,42 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
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
@auto_init()
def __init__(
self,
session: Session,
group_id: GUID,
name: str | None = None,
plural_name: str | None = None,
households_with_ingredient_food: list[str] | None = None,
**_,
) -> None:
from ..household import Household
if name is not None:
self.name_normalized = self.normalize(name)
if plural_name is not None:
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 = [
sa.UniqueConstraint("name", "group_id", name="ingredient_foods_name_group_id_key"),
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 .._model_base import BaseMixins, SqlAlchemyBase
from ..household.household_to_recipe import HouseholdToRecipe
from ..users.user_to_recipe import UserToRecipe
from .api_extras import ApiExtras, api_extras
from .assets import RecipeAsset
@ -136,7 +137,11 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
# Time Stamp Properties
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today)
date_updated: 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: Mapped[list["ShoppingListRecipeReference"]] = orm.relationship(

View file

@ -1,5 +1,6 @@
from typing import TYPE_CHECKING
from pydantic import ConfigDict
from slugify import slugify
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column
@ -10,8 +11,17 @@ from mealie.db.models._model_utils.guid import GUID
if TYPE_CHECKING:
from ..group import Group
from ..household import Household
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",
SqlAlchemyBase.metadata,
@ -40,11 +50,36 @@ class Tool(SqlAlchemyBase, BaseMixins):
name: 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(
"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()
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)
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.events import GroupEventNotifierModel
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.mealplan import GroupMealPlan, GroupMealPlanRules
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.repos.repository_cookbooks import RepositoryCookbooks
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_units import RepositoryUnit
from mealie.schema.cookbook.cookbook import ReadCookBook
@ -52,7 +53,7 @@ from mealie.schema.household.group_shopping_list import (
ShoppingListOut,
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.invite_token import ReadInviteToken
from mealie.schema.household.webhook import ReadWebhook
@ -231,6 +232,17 @@ class AllRepositories:
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
def cookbooks(self) -> RepositoryCookbooks:
return RepositoryCookbooks(

View file

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

View file

@ -8,15 +8,20 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
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.recipe import RecipeModel
from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.users import User
from mealie.repos.repository_generic import GroupRepositoryGeneric
from mealie.schema.household.household import HouseholdCreate, HouseholdInDB, UpdateHousehold
from mealie.schema.household.household_statistics import HouseholdStatistics
from mealie.repos.repository_generic import GroupRepositoryGeneric, HouseholdRepositoryGeneric
from mealie.schema.household import (
HouseholdCreate,
HouseholdInDB,
HouseholdRecipeOut,
HouseholdStatistics,
UpdateHousehold,
)
class RepositoryHousehold(GroupRepositoryGeneric[HouseholdInDB, Household]):
@ -101,3 +106,15 @@ class RepositoryHousehold(GroupRepositoryGeneric[HouseholdInDB, Household]):
total_tags=model_count(Tag, 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.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.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.settings import RecipeSettings
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.users import User
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary
from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
from mealie.schema.recipe.recipe_tool import RecipeToolOut
from mealie.schema.response.pagination import (
OrderByNullPosition,
OrderDirection,
PaginationQuery,
)
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.query_filter import QueryFilterBuilder
from ..db.models._model_base import SqlAlchemyBase
@ -39,11 +36,58 @@ from .repository_generic import HouseholdRepositoryGeneric
class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
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:
"""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
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
max_retries = 10
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()
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
self,
pagination: PaginationQuery,
@ -320,33 +319,34 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
if not params.order_by:
params.order_by = "created_at"
food_ids_with_on_hand = list(set(food_ids or []))
tool_ids_with_on_hand = list(set(tool_ids or []))
user_food_ids = list(set(food_ids or []))
user_tool_ids = list(set(tool_ids or []))
# preserve the original lists of ids before we add on_hand items
user_food_ids = food_ids_with_on_hand.copy()
user_tool_ids = tool_ids_with_on_hand.copy()
food_ids_with_on_hand = user_food_ids.copy()
tool_ids_with_on_hand = user_tool_ids.copy()
if params.include_foods_on_hand:
foods_on_hand_query = sa.select(IngredientFoodModel.id).filter(
IngredientFoodModel.on_hand == True, # noqa: E712 - required for SQLAlchemy comparison
sa.not_(IngredientFoodModel.id.in_(food_ids_with_on_hand)),
if params.include_foods_on_hand and self.user_id:
foods_on_hand_query = (
sa.select(households_to_ingredient_foods.c.food_id)
.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()
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()
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.controller import controller
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_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences
from mealie.schema.household.household_statistics import HouseholdStatistics
@ -27,6 +27,15 @@ class HouseholdSelfServiceController(BaseUserController):
"""Returns the Household Data for the Current User"""
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])
def get_household_members(self, q: PaginationQuery = Depends()):
"""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)
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])
recipes_data = cross_household_recipes.page_all(

View file

@ -46,6 +46,7 @@ class RecipeToolController(BaseUserController):
@router.put("/{item_id}", response_model=RecipeTool)
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)
@router.delete("/{item_id}", response_model=RecipeTool)

View file

@ -24,6 +24,7 @@ from mealie.core.dependencies import (
get_temporary_zip_path,
)
from mealie.pkgs import cache
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import controller
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
from mealie.schema.cookbook.cookbook import ReadCookBook
@ -252,8 +253,9 @@ class RecipeController(BaseRecipeController):
if cookbook_data is None:
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
# a household_id to filter by household. We use the "by_user" so we can sort favorites correctly.
# We use "group_recipes" here so we can return all recipes regardless of household. The query filter can
# 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=q,
cookbook=cookbook_data,
@ -288,7 +290,11 @@ class RecipeController(BaseRecipeController):
foods: list[UUID4] | None = Query(None),
tools: list[UUID4] | None = Query(None),
) -> 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)
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)
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)
@router.delete("/{item_id}", response_model=IngredientFood)

View file

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

View file

@ -1,10 +1,11 @@
from datetime import datetime
from typing import Annotated
from pydantic import UUID4, ConfigDict, StringConstraints, field_validator
from sqlalchemy.orm import joinedload, selectinload
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.schema._mealie.mealie_model import MealieModel
from mealie.schema.household.webhook import ReadWebhook
@ -13,6 +14,34 @@ from mealie.schema.response.pagination import PaginationBase
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):
group_id: UUID4 | None = None
name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]

View file

@ -59,7 +59,17 @@ class RecipeCategoryPagination(PaginationBase):
class RecipeTool(RecipeTag):
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):

View file

@ -7,7 +7,7 @@ from typing import ClassVar
from uuid import UUID, uuid4
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 mealie.db.models.recipe import IngredientFoodModel
@ -37,7 +37,6 @@ class UnitFoodBase(MealieModel):
plural_name: str | None = None
description: str = ""
extras: dict | None = {}
on_hand: bool = False
@field_validator("id", mode="before")
def convert_empty_id_to_none(cls, v):
@ -67,6 +66,7 @@ class IngredientFoodAlias(CreateIngredientFoodAlias):
class CreateIngredientFood(UnitFoodBase):
label_id: UUID4 | None = None
aliases: list[CreateIngredientFoodAlias] = []
households_with_ingredient_food: list[str] = []
class SaveIngredientFood(CreateIngredientFood):
@ -91,10 +91,24 @@ class IngredientFood(CreateIngredientFood):
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
selectinload(IngredientFoodModel.households_with_ingredient_food),
joinedload(IngredientFoodModel.extras),
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):
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.interfaces import LoaderOption
from mealie.db.models.recipe import RecipeModel, Tool
from mealie.schema._mealie import MealieModel
from ...db.models.recipe import RecipeModel, Tool
class RecipeToolCreate(MealieModel):
name: str
on_hand: bool = False
households_with_tool: list[str] = []
class RecipeToolSave(RecipeToolCreate):
@ -19,8 +18,28 @@ class RecipeToolSave(RecipeToolCreate):
class RecipeToolOut(RecipeToolCreate):
id: UUID4
slug: str
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):
recipes: list["RecipeSummary"] = []
@ -29,6 +48,7 @@ class RecipeToolResponse(RecipeToolOut):
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
selectinload(Tool.households_with_tool),
selectinload(Tool.recipes).joinedload(RecipeModel.recipe_category),
selectinload(Tool.recipes).joinedload(RecipeModel.tags),
selectinload(Tool.recipes).joinedload(RecipeModel.tools),

View file

@ -6,10 +6,10 @@ from enum import Enum
from typing import Any, TypeVar, cast
from uuid import UUID
import sqlalchemy as sa
from dateutil import parser as date_parser
from dateutil.parser import ParserError
from humps import decamelize
from sqlalchemy import ColumnElement, Select, and_, inspect, or_
from sqlalchemy.ext.associationproxy import AssociationProxyInstance
from sqlalchemy.orm import InstrumentedAttribute, Mapper
from sqlalchemy.sql import sqltypes
@ -251,17 +251,19 @@ class QueryFilterBuilder:
return f"<<{joined}>>"
@classmethod
def _consolidate_group(cls, group: list[ColumnElement], logical_operators: deque[LogicalOperator]) -> ColumnElement:
consolidated_group_builder: ColumnElement | None = None
def _consolidate_group(
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)):
if not i:
consolidated_group_builder = element
else:
operator = logical_operators.pop()
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:
consolidated_group_builder = or_(consolidated_group_builder, element)
consolidated_group_builder = sa.or_(consolidated_group_builder, element)
else:
raise ValueError(f"invalid logical operator {operator}")
@ -270,8 +272,8 @@ class QueryFilterBuilder:
@classmethod
def get_model_and_model_attr_from_attr_string(
cls, attr_string: str, model: type[Model], *, query: Select | None = None
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, Select | None]:
cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, sa.Select | None]:
"""
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.
@ -287,7 +289,7 @@ class QueryFilterBuilder:
mapper: Mapper
model_attr: InstrumentedAttribute | None = None
attribute_chain = attr_string.split(".")
attribute_chain = decamelize(attr_string).split(".")
if not attribute_chain:
raise ValueError("invalid query string: attribute name cannot be empty")
@ -306,7 +308,7 @@ class QueryFilterBuilder:
if query is not None:
query = query.join(model_attr, isouter=True)
mapper = inspect(current_model)
mapper = sa.inspect(current_model)
relationship = mapper.relationships[proxied_attribute_link]
current_model = relationship.mapper.class_
model_attr = getattr(current_model, next_attribute_link)
@ -318,7 +320,7 @@ class QueryFilterBuilder:
if query is not None:
query = query.join(model_attr, isouter=True)
mapper = inspect(current_model)
mapper = sa.inspect(current_model)
relationship = mapper.relationships[attribute_link]
current_model = relationship.mapper.class_
@ -330,7 +332,56 @@ class QueryFilterBuilder:
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
attr_model_map: dict[int, Any] = {}
model_attr: InstrumentedAttribute
@ -344,8 +395,8 @@ class QueryFilterBuilder:
attr_model_map[i] = nested_model
# build query filter
partial_group: list[ColumnElement] = []
partial_group_stack: deque[list[ColumnElement]] = deque()
partial_group: list[sa.ColumnElement] = []
partial_group_stack: deque[list[sa.ColumnElement]] = deque()
logical_operator_stack: deque[LogicalOperator] = deque()
for i, component in enumerate(self.filter_components):
if component == self.l_group_sep:
@ -365,43 +416,13 @@ class QueryFilterBuilder:
else:
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 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 = 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}")
if (column_alias := column_aliases.get(base_attribute_name)) is not None:
model_attr = column_alias
element = self._get_filter_element(component, model, model_attr, model_attr.type)
partial_group.append(element)
# combine the completed groups into one filter

View file

@ -1,10 +1,15 @@
from uuid import UUID
from pydantic import UUID4
from mealie.core import exceptions
from mealie.repos.all_repositories import get_repositories
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_statistics import HouseholdStatistics
from mealie.schema.recipe.recipe import Recipe
from mealie.services._base_service import BaseService
@ -15,6 +20,19 @@ class HouseholdService(BaseService):
self.repos = repos
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
def create_household(
repos: AllRepositories, h_base: HouseholdCreate, prefs: CreateHouseholdPreferences | None = None
@ -48,3 +66,34 @@ class HouseholdService(BaseService):
household_id = household_id or self.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.repository_factory import AllRepositories
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.recipe.recipe import CreateRecipe, Recipe
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.user.user import PrivateUser, UserRatingCreate
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.recipe.recipe_data_service import RecipeDataService
from mealie.services.scraper import cleaner
@ -173,6 +174,7 @@ class RecipeService(RecipeServiceBase):
data.settings = RecipeSettings()
rating_input = data.rating
data.last_made = None
new_recipe = self.repos.recipes.create(data)
# convert rating into user rating
@ -342,6 +344,7 @@ class RecipeService(RecipeServiceBase):
if old_recipe.recipe_ingredient is None
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())
@ -413,8 +416,11 @@ class RecipeService(RecipeServiceBase):
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,
# 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:
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.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.recipe.recipe import RecipeSummary
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
@ -18,12 +19,14 @@ from mealie.services.event_bus_service.event_types import (
EventRecipeTimelineEventData,
EventTypes,
)
from mealie.services.household_services.household_service import HouseholdService
def _create_mealplan_timeline_events_for_household(
event_time: datetime, session: Session, group_id: UUID4, household_id: UUID4
) -> None:
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)
timeline_events_to_create: list[RecipeTimelineEventCreate] = []
@ -64,7 +67,8 @@ def _create_mealplan_timeline_events_for_household(
continue
# 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:
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():
household_service.set_household_recipe(recipe.slug, HouseholdRecipeUpdate(last_made=event_time))
repos.recipes.patch(recipe.slug, {"last_made": event_time})
event_bus_service.dispatch(
integration_id=DEFAULT_INTEGRATION_ID,

View file

@ -138,6 +138,9 @@ exclude = [
# Assume Python 3.12.
target-version = "py312"
[tool.ruff.lint.isort]
known-third-party = ["alembic"]
[tool.ruff.lint]
# Enable Pyflakes `E` and `F` codes by default.
ignore = ["F403", "TID252", "B008"]
@ -158,8 +161,7 @@ select = [
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["E402", "E501"]
"mealie/alembic/versions/2022*" = ["E501", "I001"]
"mealie/alembic/versions/2023*" = ["E501", "I001"]
"mealie/alembic/versions/*" = ["E501", "I001"]
"dev/scripts/all_recipes_stress_test.py" = ["E501"]
"ldap_provider.py" = ["UP032"]
"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 mealie.db.models.household import HouseholdToRecipe
from mealie.schema.recipe.recipe import Recipe
from tests.utils import api_routes
from tests.utils.factories import random_string
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_2.user_id) 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
from dateutil.parser import parse as parse_dt
from fastapi.testclient import TestClient
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
h2_recipe = h2_user.repos.recipes.get_one(response.json())
assert h2_recipe and h2_recipe.id
h2_recipe_id = h2_recipe.id
h2_recipe_slug = h2_recipe.slug
response = api_client.get(api_routes.recipes_slug(h2_recipe_slug), headers=unique_user.token)
assert response.status_code == 200
recipe = response.json()
assert recipe["id"] == str(h2_recipe_id)
old_last_made = recipe["lastMade"]
dt_1 = datetime.now(tz=UTC)
dt_2 = dt_1 + timedelta(days=2)
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(
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
# confirm the last made date was updated
response = api_client.get(api_routes.recipes_slug(h2_recipe_slug), headers=unique_user.token)
response = api_client.get(api_routes.households_self_recipes_recipe_slug(h2_recipe_slug), headers=unique_user.token)
assert response.status_code == 200
recipe = response.json()
assert recipe["id"] == str(h2_recipe_id)
new_last_made = recipe["lastMade"]
assert new_last_made == now != old_last_made
assert (last_made_json := response.json()["lastMade"])
assert parse_dt(last_made_json) == dt_2
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):

View file

@ -14,14 +14,32 @@ from tests.utils.fixture_schemas import TestUser
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(
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):
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(
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:
response = api_client.get(
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,
)
response.raise_for_status()
@ -579,3 +597,61 @@ def test_include_cross_household_recipes(api_client: TestClient, unique_user: Te
finally:
unique_user.repos.recipes.delete(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,
PaginationQuery,
)
from mealie.schema.user.user import UserRatingUpdate
from mealie.services.seeder.seeder_service import SeederService
from tests.utils import api_routes
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"]
assert recipe_id in recipe_ids[i]
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 uuid import UUID
@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories
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.recipe import Recipe, RecipeCategory, RecipeSummary
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)
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]):
user_1, user_2 = user_tuple
database = user_1.repos

View file

@ -1,9 +1,8 @@
import filecmp
import statistics
from pathlib import Path
from typing import Any, cast
from typing import Any
import pytest
from sqlalchemy.orm import Session
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.group import Group
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.shopping_list import ShoppingList
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
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.users import User
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
@ -74,64 +76,9 @@ def test_database_restore():
assert snapshop_1[s1].sort(key=dict_sorter) == snapshop_2[s2].sort(key=dict_sorter)
@pytest.mark.parametrize(
"backup_path",
[
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,
],
ids=[
"44e8d670719d_1: add extras to shopping lists, list items, and ingredient foods",
"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",
"bcfdad6b7355_1: remove tool name and slug unique contraints",
"ba1e4a6cfe99_1: added plural names and alias tables for foods and units",
"09aba125b57a_1: add OIDC auth method (Safari-mangled ZIP structure)",
"86054b40fd06_1: added query_filter_string to cookbook and mealplan",
],
)
def test_database_restore_data(backup_path: Path):
"""
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.
This test should verify all migrations that do some sort of database manipulation (e.g. populating a new column).
If a new migration is added that does any sort of data manipulation, this test should be updated.
"""
settings = get_app_settings()
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()
try:
assert backup_path.exists()
backup_v2.restore(backup_path)
# make sure migrations populated data successfully
with session_context() as session:
session = cast(Session, session)
groups = session.query(Group).all()
def _5ab195a474eb_add_normalized_search_properties(session: Session):
recipes = session.query(RecipeModel).all()
shopping_lists = session.query(ShoppingList).all()
labels = session.query(MultiPurposeLabel).all()
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
@ -144,7 +91,11 @@ def test_database_restore_data(backup_path: Path):
if ingredient.original_text:
assert ingredient.original_text_normalized
# 2023-02-21-22.03.19_b04a08da2108_added_shopping_list_label_settings
def _b04a08da2108_added_shopping_list_label_settings(session: Session):
shopping_lists = session.query(ShoppingList).all()
labels = session.query(MultiPurposeLabel).all()
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)
@ -155,11 +106,18 @@ def test_database_restore_data(backup_path: Path):
):
assert label_setting.label == label
# 2023-08-06-21.00.34_04ac51cbe9a4_added_group_slug
def _04ac51cbe9a4_added_group_slug(session: Session):
groups = session.query(Group).all()
for group in groups:
assert group.slug
# 2023-09-01-14.55.42_0341b154f79a_added_normalized_unit_and_food_names
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
@ -169,7 +127,10 @@ def test_database_restore_data(backup_path: Path):
if unit.abbreviation:
assert unit.abbreviation_normalized
# 2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings
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)
@ -181,7 +142,11 @@ def test_database_restore_data(backup_path: Path):
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
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:
@ -215,5 +180,91 @@ def test_database_restore_data(backup_path: Path):
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
relationships and data types should be preserved.
This test should verify all migrations that do some sort of database manipulation (e.g. populating a new column).
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()
backup_v2 = BackupV2(settings.DB_URL)
original_data_backup = backup_v2.backup()
try:
for backup_path in backup_paths:
assert backup_path.exists()
backup_v2.restore(backup_path)
with session_context() as session:
for migration_func in migration_funcs:
try:
migration_func(session)
except Exception as e:
session.rollback()
raise Exception(
f'Migration "{migration_func.__name__}" failed on backup "{backup_path}"'
) from e
finally:
backup_v2.restore(original_data_backup)

View file

@ -1,12 +1,13 @@
from datetime import UTC, datetime, timedelta
from dateutil.parser import parse as parse_dt
from fastapi.testclient import TestClient
from pydantic import UUID4
from mealie.schema.household.household import HouseholdRecipeSummary
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 tests import utils
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
@ -17,7 +18,7 @@ def test_no_mealplans():
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)
response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token)
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)
new_recipe_data: dict = response.json()
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
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
# 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):
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
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)
response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token)
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)
future_dt = datetime.now(UTC) + timedelta(days=random_int(1, 10))
recipe.last_made = future_dt
response = api_client.put(
api_routes.recipes_slug(recipe.slug), json=utils.jsonify(recipe), headers=unique_user.token
response = api_client.patch(
api_routes.recipes_slug_last_made(recipe.slug),
data=RecipeLastMade(timestamp=future_dt).model_dump_json(),
headers=unique_user.token,
)
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(
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)
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()
response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token)
recipe = RecipeSummary.model_validate(response.json())
assert recipe.last_made == future_dt
response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=unique_user.token)
assert response.status_code == 200
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}"
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):
"""`/api/households/shopping/items/{item_id}`"""
return f"{prefix}/households/shopping/items/{item_id}"