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:
parent
e565b919df
commit
e9892aba89
53 changed files with 1618 additions and 400 deletions
File diff suppressed because one or more lines are too long
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'"
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -9,7 +9,6 @@ export const useTools = function (eager = true) {
|
|||
id: "",
|
||||
name: "",
|
||||
slug: "",
|
||||
onHand: false,
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
|
|
|
@ -13,7 +13,6 @@ export const useFoodData = function () {
|
|||
name: "",
|
||||
description: "",
|
||||
labelId: undefined,
|
||||
onHand: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -161,7 +161,7 @@ export interface RecipeTool {
|
|||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
onHand?: boolean;
|
||||
householdsWithTool?: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface CustomPageImport {
|
||||
|
|
|
@ -97,7 +97,7 @@ export interface RecipeTool {
|
|||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
onHand?: boolean;
|
||||
householdsWithTool?: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface SaveCookBook {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -117,7 +117,7 @@ export interface RecipeTool {
|
|||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
onHand?: boolean;
|
||||
householdsWithTool?: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface SavePlanEntry {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ###
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
60
mealie/db/models/household/household_to_recipe.py
Normal file
60
mealie/db/models/household/household_to_recipe.py
Normal 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()
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue