mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-06-02 13:15:23 -04:00
feat: merge food into another (#1143)
* setup food repository * add merge route and payloads * remove type checking * generate types * implement merge dialog * food repo tests * split install from workflow * bum dependencies * revert changes * update copy * refactor URLs to avoid incorrect template being used * stick advanced items under developer mode * use utility component for advanced feature
This commit is contained in:
parent
10784b6e24
commit
b93dae109e
2
.github/workflows/backend-tests.yml
vendored
2
.github/workflows/backend-tests.yml
vendored
@ -32,7 +32,6 @@ jobs:
|
|||||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
|
|
||||||
# Steps
|
# Steps
|
||||||
steps:
|
steps:
|
||||||
#----------------------------------------------
|
#----------------------------------------------
|
||||||
@ -70,6 +69,7 @@ jobs:
|
|||||||
poetry install
|
poetry install
|
||||||
poetry add "psycopg2-binary==2.8.6"
|
poetry add "psycopg2-binary==2.8.6"
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
#----------------------------------------------
|
#----------------------------------------------
|
||||||
# run test suite
|
# run test suite
|
||||||
#----------------------------------------------
|
#----------------------------------------------
|
||||||
|
34
.vscode/settings.json
vendored
34
.vscode/settings.json
vendored
@ -5,11 +5,7 @@
|
|||||||
"backend",
|
"backend",
|
||||||
"code-generation"
|
"code-generation"
|
||||||
],
|
],
|
||||||
"cSpell.enableFiletypes": [
|
"cSpell.enableFiletypes": ["!javascript", "!python", "!yaml"],
|
||||||
"!javascript",
|
|
||||||
"!python",
|
|
||||||
"!yaml"
|
|
||||||
],
|
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"chowdown",
|
"chowdown",
|
||||||
"compression",
|
"compression",
|
||||||
@ -24,9 +20,7 @@
|
|||||||
"source.organizeImports": false
|
"source.organizeImports": false
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"eslint.workingDirectories": [
|
"eslint.workingDirectories": ["./frontend"],
|
||||||
"./frontend"
|
|
||||||
],
|
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"**/__pycache__": true,
|
"**/__pycache__": true,
|
||||||
"**/.DS_Store": true,
|
"**/.DS_Store": true,
|
||||||
@ -35,9 +29,7 @@
|
|||||||
"**/.svn": true,
|
"**/.svn": true,
|
||||||
"**/CVS": true
|
"**/CVS": true
|
||||||
},
|
},
|
||||||
"i18n-ally.enabledFrameworks": [
|
"i18n-ally.enabledFrameworks": ["vue"],
|
||||||
"vue"
|
|
||||||
],
|
|
||||||
"i18n-ally.keystyle": "nested",
|
"i18n-ally.keystyle": "nested",
|
||||||
"i18n-ally.localesPaths": "frontend/lang/messages",
|
"i18n-ally.localesPaths": "frontend/lang/messages",
|
||||||
"i18n-ally.sourceLanguage": "en-US",
|
"i18n-ally.sourceLanguage": "en-US",
|
||||||
@ -45,26 +37,14 @@
|
|||||||
"python.linting.enabled": true,
|
"python.linting.enabled": true,
|
||||||
"python.linting.flake8Enabled": true,
|
"python.linting.flake8Enabled": true,
|
||||||
"python.linting.pylintEnabled": false,
|
"python.linting.pylintEnabled": false,
|
||||||
"python.linting.pylintArgs": [
|
"python.linting.pylintArgs": ["--rcfile=${workspaceFolder}/.pylintrc"],
|
||||||
"--rcfile=${workspaceFolder}/.pylintrc"
|
|
||||||
],
|
|
||||||
"python.testing.autoTestDiscoverOnSaveEnabled": false,
|
"python.testing.autoTestDiscoverOnSaveEnabled": false,
|
||||||
"python.testing.pytestArgs": [
|
"python.testing.pytestArgs": ["tests"],
|
||||||
"tests"
|
|
||||||
],
|
|
||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.analysis.typeCheckingMode": "basic",
|
"python.analysis.typeCheckingMode": "off",
|
||||||
"python.linting.mypyEnabled": true,
|
"python.linting.mypyEnabled": true,
|
||||||
"python.sortImports.path": "${workspaceFolder}/.venv/bin/isort",
|
"python.sortImports.path": "${workspaceFolder}/.venv/bin/isort",
|
||||||
"search.mode": "reuseEditor",
|
"search.mode": "reuseEditor",
|
||||||
"vetur.validation.template": false,
|
"python.testing.unittestArgs": ["-v", "-s", "./tests", "-p", "test_*.py"]
|
||||||
"coverage-gutters.lcovname": "${workspaceFolder}/.coverage",
|
|
||||||
"python.testing.unittestArgs": [
|
|
||||||
"-v",
|
|
||||||
"-s",
|
|
||||||
"./tests",
|
|
||||||
"-p",
|
|
||||||
"test_*.py"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ In your instance of Mealie prior to v1, perform an export of your data in the Ad
|
|||||||
|
|
||||||
## Step 3: Using the Migration Tool
|
## Step 3: Using the Migration Tool
|
||||||
|
|
||||||
In your new v1 instance, navigate to `/group/data/migrations` and select "Mealie" from the dropdown selector. Upload the exported data from your pre-v1 instance. Optionally, you can tag all the recipes you've imported with the `mealie_alpha` tag to help you identify them. Once the upload has finished, submit the form and your migration will begin. This may take some time, but when it's complete you'll be provided a new entry in hte "Previous Migrations" table below. Be sure to review the migration report to make sure everything was successful. There may be instances where some of the recipes were not imported, but the migration will still import all the successful recipes.
|
In your new v1 instance, navigate to `/group/migrations` and select "Mealie" from the dropdown selector. Upload the exported data from your pre-v1 instance. Optionally, you can tag all the recipes you've imported with the `mealie_alpha` tag to help you identify them. Once the upload has finished, submit the form and your migration will begin. This may take some time, but when it's complete you'll be provided a new entry in hte "Previous Migrations" table below. Be sure to review the migration report to make sure everything was successful. There may be instances where some of the recipes were not imported, but the migration will still import all the successful recipes.
|
||||||
|
|
||||||
In most cases, it's faster to manually migrate the recipes that didn't take instead of trying to identify why the recipes failed to import. If you're experiencing issues with the migration tool, please open an issue on GitHub.
|
In most cases, it's faster to manually migrate the recipes that didn't take instead of trying to identify why the recipes failed to import. If you're experiencing issues with the migration tool, please open an issue on GitHub.
|
||||||
|
|
||||||
|
@ -6,9 +6,15 @@ const prefix = "/api";
|
|||||||
const routes = {
|
const routes = {
|
||||||
food: `${prefix}/foods`,
|
food: `${prefix}/foods`,
|
||||||
foodsFood: (tag: string) => `${prefix}/foods/${tag}`,
|
foodsFood: (tag: string) => `${prefix}/foods/${tag}`,
|
||||||
|
merge: `${prefix}/foods/merge`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class FoodAPI extends BaseCRUDAPI<IngredientFood, CreateIngredientFood> {
|
export class FoodAPI extends BaseCRUDAPI<IngredientFood, CreateIngredientFood> {
|
||||||
baseRoute: string = routes.food;
|
baseRoute: string = routes.food;
|
||||||
itemRoute = routes.foodsFood;
|
itemRoute = routes.foodsFood;
|
||||||
|
|
||||||
|
merge(fromId: string, toId: string) {
|
||||||
|
// @ts-ignore TODO: fix this
|
||||||
|
return this.requests.put<IngredientFood>(routes.merge, { fromFood: fromId, toFood: toId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,7 +48,7 @@ export default defineComponent({
|
|||||||
];
|
];
|
||||||
|
|
||||||
function handleRowClick(item: ReportSummary) {
|
function handleRowClick(item: ReportSummary) {
|
||||||
router.push("/group/data/reports/" + item.id);
|
router.push("/group/reports/" + item.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function capitalize(str: string) {
|
function capitalize(str: string) {
|
||||||
@ -69,5 +69,4 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped></style>
|
||||||
</style>
|
|
||||||
|
@ -6,7 +6,8 @@
|
|||||||
:top-link="topLinks"
|
:top-link="topLinks"
|
||||||
:bottom-links="bottomLinks"
|
:bottom-links="bottomLinks"
|
||||||
:user="{ data: true }"
|
:user="{ data: true }"
|
||||||
:secondary-header="$t('user.admin')"
|
secondary-header="Developer"
|
||||||
|
:secondary-links="developerLinks"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TheSnackbar />
|
<TheSnackbar />
|
||||||
@ -49,11 +50,7 @@ export default defineComponent({
|
|||||||
to: "/admin/site-settings",
|
to: "/admin/site-settings",
|
||||||
title: i18n.t("sidebar.site-settings"),
|
title: i18n.t("sidebar.site-settings"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: $globals.icons.wrench,
|
|
||||||
to: "/admin/maintenance",
|
|
||||||
title: "Maintenance",
|
|
||||||
},
|
|
||||||
// {
|
// {
|
||||||
// icon: $globals.icons.chart,
|
// icon: $globals.icons.chart,
|
||||||
// to: "/admin/analytics",
|
// to: "/admin/analytics",
|
||||||
@ -74,6 +71,14 @@ export default defineComponent({
|
|||||||
to: "/admin/backups",
|
to: "/admin/backups",
|
||||||
title: i18n.t("sidebar.backups"),
|
title: i18n.t("sidebar.backups"),
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const developerLinks: SidebarLinks = [
|
||||||
|
{
|
||||||
|
icon: $globals.icons.wrench,
|
||||||
|
to: "/admin/maintenance",
|
||||||
|
title: "Maintenance",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.check,
|
icon: $globals.icons.check,
|
||||||
to: "/admin/background-tasks",
|
to: "/admin/background-tasks",
|
||||||
@ -98,6 +103,7 @@ export default defineComponent({
|
|||||||
sidebar,
|
sidebar,
|
||||||
topLinks,
|
topLinks,
|
||||||
bottomLinks,
|
bottomLinks,
|
||||||
|
developerLinks,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -97,7 +97,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<v-container class="mt-4 d-flex justify-end">
|
<v-container class="mt-4 d-flex justify-end">
|
||||||
<v-btn outlined rounded to="/group/data/migrations"> Looking For Migrations? </v-btn>
|
<v-btn outlined rounded to="/group/migrations"> Looking For Migrations? </v-btn>
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
@ -1,5 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- Merge Dialog -->
|
||||||
|
<BaseDialog v-model="mergeDialog" :icon="$globals.icons.foods" title="Combine Food" @confirm="mergeFoods">
|
||||||
|
<v-card-text>
|
||||||
|
Combining the selected foods will merge the Source Food and Target Food into a single food. The
|
||||||
|
<strong> Source Food will be deleted </strong> and all of the references to the Source Food will be updated to
|
||||||
|
point to the Target Food.
|
||||||
|
<v-autocomplete v-model="fromFood" return-object :items="foods" item-text="name" label="Source Food" />
|
||||||
|
<v-autocomplete v-model="toFood" return-object :items="foods" item-text="name" label="Target Food" />
|
||||||
|
|
||||||
|
<template v-if="canMerge && fromFood && toFood">
|
||||||
|
<div class="text-center">Merging {{ fromFood.name }} into {{ toFood.name }}</div>
|
||||||
|
</template>
|
||||||
|
</v-card-text>
|
||||||
|
</BaseDialog>
|
||||||
|
|
||||||
<!-- Edit Dialog -->
|
<!-- Edit Dialog -->
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-model="editDialog"
|
v-model="editDialog"
|
||||||
@ -48,7 +63,7 @@
|
|||||||
@edit-one="editEventHandler"
|
@edit-one="editEventHandler"
|
||||||
>
|
>
|
||||||
<template #button-row>
|
<template #button-row>
|
||||||
<BaseButton :disabled="true">
|
<BaseButton @click="mergeDialog = true">
|
||||||
<template #icon> {{ $globals.icons.foods }} </template>
|
<template #icon> {{ $globals.icons.foods }} </template>
|
||||||
Combine
|
Combine
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
@ -64,6 +79,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, onMounted, ref } from "@nuxtjs/composition-api";
|
import { defineComponent, onMounted, ref } from "@nuxtjs/composition-api";
|
||||||
|
import { computed } from "vue-demi";
|
||||||
import { validators } from "~/composables/use-validators";
|
import { validators } from "~/composables/use-validators";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { IngredientFood } from "~/types/api-types/recipe";
|
import { IngredientFood } from "~/types/api-types/recipe";
|
||||||
@ -144,6 +160,29 @@ export default defineComponent({
|
|||||||
deleteDialog.value = false;
|
deleteDialog.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Merge Foods
|
||||||
|
|
||||||
|
const mergeDialog = ref(false);
|
||||||
|
const fromFood = ref<IngredientFood | null>(null);
|
||||||
|
const toFood = ref<IngredientFood | null>(null);
|
||||||
|
|
||||||
|
const canMerge = computed(() => {
|
||||||
|
return fromFood.value && toFood.value && fromFood.value.id !== toFood.value.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mergeFoods() {
|
||||||
|
if (!canMerge.value || !fromFood.value || !toFood.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await userApi.foods.merge(fromFood.value.id, toFood.value.id);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
refreshFoods();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Labels
|
// Labels
|
||||||
|
|
||||||
@ -170,6 +209,12 @@ export default defineComponent({
|
|||||||
deleteEventHandler,
|
deleteEventHandler,
|
||||||
deleteDialog,
|
deleteDialog,
|
||||||
deleteFood,
|
deleteFood,
|
||||||
|
// Merge
|
||||||
|
canMerge,
|
||||||
|
mergeFoods,
|
||||||
|
mergeDialog,
|
||||||
|
fromFood,
|
||||||
|
toFood,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -312,7 +312,7 @@
|
|||||||
|
|
||||||
<AdvancedOnly>
|
<AdvancedOnly>
|
||||||
<v-container class="narrow-container d-flex justify-end">
|
<v-container class="narrow-container d-flex justify-end">
|
||||||
<v-btn outlined rounded to="/group/data/migrations"> Looking For Migrations? </v-btn>
|
<v-btn outlined rounded to="/group/migrations"> Looking For Migrations? </v-btn>
|
||||||
</v-container>
|
</v-container>
|
||||||
</AdvancedOnly>
|
</AdvancedOnly>
|
||||||
</div>
|
</div>
|
||||||
|
@ -98,7 +98,8 @@
|
|||||||
Manage your preferences, change your password, and update your email
|
Manage your preferences, change your password, and update your email
|
||||||
</UserProfileLinkCard>
|
</UserProfileLinkCard>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
<AdvancedOnly>
|
||||||
|
<v-col cols="12" sm="12" md="6">
|
||||||
<UserProfileLinkCard
|
<UserProfileLinkCard
|
||||||
:link="{ text: 'Manage Your API Tokens', to: '/user/profile/api-tokens' }"
|
:link="{ text: 'Manage Your API Tokens', to: '/user/profile/api-tokens' }"
|
||||||
:image="require('~/static/svgs/manage-api-tokens.svg')"
|
:image="require('~/static/svgs/manage-api-tokens.svg')"
|
||||||
@ -107,6 +108,7 @@
|
|||||||
Manage your API Tokens for access from external applications
|
Manage your API Tokens for access from external applications
|
||||||
</UserProfileLinkCard>
|
</UserProfileLinkCard>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
</AdvancedOnly>
|
||||||
</v-row>
|
</v-row>
|
||||||
</section>
|
</section>
|
||||||
<v-divider class="my-7"></v-divider>
|
<v-divider class="my-7"></v-divider>
|
||||||
@ -134,24 +136,6 @@
|
|||||||
Manage a collection of recipe categories and generate pages for them.
|
Manage a collection of recipe categories and generate pages for them.
|
||||||
</UserProfileLinkCard>
|
</UserProfileLinkCard>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
|
||||||
<UserProfileLinkCard
|
|
||||||
:link="{ text: 'Manage Webhooks', to: '/group/webhooks' }"
|
|
||||||
:image="require('~/static/svgs/manage-webhooks.svg')"
|
|
||||||
>
|
|
||||||
<template #title> Webhooks </template>
|
|
||||||
Setup webhooks that trigger on days that you have have mealplan scheduled.
|
|
||||||
</UserProfileLinkCard>
|
|
||||||
</v-col>
|
|
||||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
|
||||||
<UserProfileLinkCard
|
|
||||||
:link="{ text: 'Manage Notifiers', to: '/group/notifiers' }"
|
|
||||||
:image="require('~/static/svgs/manage-notifiers.svg')"
|
|
||||||
>
|
|
||||||
<template #title> Notifiers </template>
|
|
||||||
Setup email and push notifications that trigger on specific events.
|
|
||||||
</UserProfileLinkCard>
|
|
||||||
</v-col>
|
|
||||||
<v-col v-if="user.canManage" cols="12" sm="12" md="6">
|
<v-col v-if="user.canManage" cols="12" sm="12" md="6">
|
||||||
<UserProfileLinkCard
|
<UserProfileLinkCard
|
||||||
:link="{ text: 'Manage Members', to: '/group/members' }"
|
:link="{ text: 'Manage Members', to: '/group/members' }"
|
||||||
@ -161,16 +145,30 @@
|
|||||||
See who's in your group and manage their permissions.
|
See who's in your group and manage their permissions.
|
||||||
</UserProfileLinkCard>
|
</UserProfileLinkCard>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<AdvancedOnly>
|
||||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
||||||
<UserProfileLinkCard
|
<UserProfileLinkCard
|
||||||
:link="{ text: 'Manage Recipe Data', to: '/group/data/recipes' }"
|
:link="{ text: 'Manage Webhooks', to: '/group/webhooks' }"
|
||||||
:image="require('~/static/svgs/manage-recipes.svg')"
|
:image="require('~/static/svgs/manage-webhooks.svg')"
|
||||||
>
|
>
|
||||||
<template #title> Recipe Data </template>
|
<template #title> Webhooks </template>
|
||||||
Manage your recipe data and make bulk changes
|
Setup webhooks that trigger on days that you have have mealplan scheduled.
|
||||||
</UserProfileLinkCard>
|
</UserProfileLinkCard>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
</AdvancedOnly>
|
||||||
|
<AdvancedOnly>
|
||||||
|
<v-col cols="12" sm="12" md="6">
|
||||||
|
<UserProfileLinkCard
|
||||||
|
:link="{ text: 'Manage Notifiers', to: '/group/notifiers' }"
|
||||||
|
:image="require('~/static/svgs/manage-notifiers.svg')"
|
||||||
|
>
|
||||||
|
<template #title> Notifiers </template>
|
||||||
|
Setup email and push notifications that trigger on specific events.
|
||||||
|
</UserProfileLinkCard>
|
||||||
|
</v-col>
|
||||||
|
</AdvancedOnly>
|
||||||
|
<AdvancedOnly>
|
||||||
|
<v-col cols="12" sm="12" md="6">
|
||||||
<UserProfileLinkCard
|
<UserProfileLinkCard
|
||||||
:link="{ text: 'Manage Data', to: '/group/data/foods' }"
|
:link="{ text: 'Manage Data', to: '/group/data/foods' }"
|
||||||
:image="require('~/static/svgs/manage-recipes.svg')"
|
:image="require('~/static/svgs/manage-recipes.svg')"
|
||||||
@ -179,15 +177,18 @@
|
|||||||
Manage your Food and Units (more options coming soon)
|
Manage your Food and Units (more options coming soon)
|
||||||
</UserProfileLinkCard>
|
</UserProfileLinkCard>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
</AdvancedOnly>
|
||||||
|
<AdvancedOnly>
|
||||||
|
<v-col cols="12" sm="12" md="6">
|
||||||
<UserProfileLinkCard
|
<UserProfileLinkCard
|
||||||
:link="{ text: 'Manage Data Migrations', to: '/group/data/migrations' }"
|
:link="{ text: 'Manage Data Migrations', to: '/group/migrations' }"
|
||||||
:image="require('~/static/svgs/manage-data-migrations.svg')"
|
:image="require('~/static/svgs/manage-data-migrations.svg')"
|
||||||
>
|
>
|
||||||
<template #title> Data Migrations </template>
|
<template #title> Data Migrations </template>
|
||||||
Migrate your existing data from other applications like Nextcloud Recipes and Chowdown
|
Migrate your existing data from other applications like Nextcloud Recipes and Chowdown
|
||||||
</UserProfileLinkCard>
|
</UserProfileLinkCard>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
</AdvancedOnly>
|
||||||
</v-row>
|
</v-row>
|
||||||
</section>
|
</section>
|
||||||
</v-container>
|
</v-container>
|
||||||
|
@ -113,6 +113,10 @@ export interface MultiPurposeLabelSummary {
|
|||||||
groupId: string;
|
groupId: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
export interface IngredientMerge {
|
||||||
|
fromFood: string;
|
||||||
|
toFood: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* A list of ingredient references.
|
* A list of ingredient references.
|
||||||
*/
|
*/
|
||||||
|
2
frontend/types/components.d.ts
vendored
2
frontend/types/components.d.ts
vendored
@ -33,6 +33,7 @@ import AppHeader from "@/components/layout/AppHeader.vue";
|
|||||||
import AppSidebar from "@/components/layout/AppSidebar.vue";
|
import AppSidebar from "@/components/layout/AppSidebar.vue";
|
||||||
import AppFooter from "@/components/layout/AppFooter.vue";
|
import AppFooter from "@/components/layout/AppFooter.vue";
|
||||||
|
|
||||||
|
|
||||||
declare module "vue" {
|
declare module "vue" {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
// Global Components
|
// Global Components
|
||||||
@ -69,6 +70,7 @@ declare module "vue" {
|
|||||||
AppHeader: typeof AppHeader;
|
AppHeader: typeof AppHeader;
|
||||||
AppSidebar: typeof AppSidebar;
|
AppSidebar: typeof AppSidebar;
|
||||||
AppFooter: typeof AppFooter;
|
AppFooter: typeof AppFooter;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,6 +27,7 @@ from mealie.db.models.recipe.tool import Tool
|
|||||||
from mealie.db.models.server.task import ServerTaskModel
|
from mealie.db.models.server.task import ServerTaskModel
|
||||||
from mealie.db.models.users import LongLiveToken, User
|
from mealie.db.models.users import LongLiveToken, User
|
||||||
from mealie.db.models.users.password_reset import PasswordResetModel
|
from mealie.db.models.users.password_reset import PasswordResetModel
|
||||||
|
from mealie.repos.repository_foods import RepositoryFood
|
||||||
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
|
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
|
||||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||||
from mealie.schema.group.group_events import GroupEventNotifierOut
|
from mealie.schema.group.group_events import GroupEventNotifierOut
|
||||||
@ -94,8 +95,8 @@ class AllRepositories:
|
|||||||
return RepositoryRecipes(self.session, PK_SLUG, RecipeModel, Recipe)
|
return RepositoryRecipes(self.session, PK_SLUG, RecipeModel, Recipe)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def ingredient_foods(self) -> RepositoryGeneric[IngredientFood, IngredientFoodModel]:
|
def ingredient_foods(self) -> RepositoryFood:
|
||||||
return RepositoryGeneric(self.session, PK_ID, IngredientFoodModel, IngredientFood)
|
return RepositoryFood(self.session, PK_ID, IngredientFoodModel, IngredientFood)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def ingredient_units(self) -> RepositoryGeneric[IngredientUnit, IngredientUnitModel]:
|
def ingredient_units(self) -> RepositoryGeneric[IngredientUnit, IngredientUnitModel]:
|
||||||
|
32
mealie/repos/repository_foods.py
Normal file
32
mealie/repos/repository_foods.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from pydantic import UUID4
|
||||||
|
|
||||||
|
from mealie.db.models.recipe.ingredient import IngredientFoodModel
|
||||||
|
from mealie.schema.recipe.recipe_ingredient import IngredientFood
|
||||||
|
|
||||||
|
from .repository_generic import RepositoryGeneric
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryFood(RepositoryGeneric[IngredientFood, IngredientFoodModel]):
|
||||||
|
def merge(self, from_food: UUID4, to_food: UUID4) -> IngredientFood | None:
|
||||||
|
|
||||||
|
from_model: IngredientFoodModel = (
|
||||||
|
self.session.query(self.sql_model).filter_by(**self._filter_builder(**{"id": from_food})).one()
|
||||||
|
)
|
||||||
|
|
||||||
|
to_model: IngredientFoodModel = (
|
||||||
|
self.session.query(self.sql_model).filter_by(**self._filter_builder(**{"id": to_food})).one()
|
||||||
|
)
|
||||||
|
|
||||||
|
to_model.ingredients += from_model.ingredients
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.session.delete(from_model)
|
||||||
|
self.session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
self.session.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
return self.get_one(to_food)
|
||||||
|
|
||||||
|
def by_group(self, group_id: UUID4) -> "RepositoryFood":
|
||||||
|
return super().by_group(group_id) # type: ignore
|
@ -1,6 +1,6 @@
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
|
|
||||||
from mealie.routes._base.abc_controller import BaseUserController
|
from mealie.routes._base.abc_controller import BaseUserController
|
||||||
@ -8,7 +8,13 @@ from mealie.routes._base.controller import controller
|
|||||||
from mealie.routes._base.mixins import CrudMixins
|
from mealie.routes._base.mixins import CrudMixins
|
||||||
from mealie.schema import mapper
|
from mealie.schema import mapper
|
||||||
from mealie.schema.query import GetAll
|
from mealie.schema.query import GetAll
|
||||||
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, IngredientFood, SaveIngredientFood
|
from mealie.schema.recipe.recipe_ingredient import (
|
||||||
|
CreateIngredientFood,
|
||||||
|
IngredientFood,
|
||||||
|
IngredientMerge,
|
||||||
|
SaveIngredientFood,
|
||||||
|
)
|
||||||
|
from mealie.schema.response.responses import SuccessResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/foods", tags=["Recipes: Foods"])
|
router = APIRouter(prefix="/foods", tags=["Recipes: Foods"])
|
||||||
|
|
||||||
@ -27,6 +33,15 @@ class IngredientFoodsController(BaseUserController):
|
|||||||
self.registered_exceptions,
|
self.registered_exceptions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.put("/merge", response_model=SuccessResponse)
|
||||||
|
def merge_one(self, data: IngredientMerge):
|
||||||
|
try:
|
||||||
|
self.repo.merge(data.from_food, data.to_food)
|
||||||
|
return SuccessResponse.respond("Successfully merged foods")
|
||||||
|
except Exception as e:
|
||||||
|
self.deps.logger.error(e)
|
||||||
|
raise HTTPException(500, "Failed to merge foods") from e
|
||||||
|
|
||||||
@router.get("", response_model=list[IngredientFood])
|
@router.get("", response_model=list[IngredientFood])
|
||||||
def get_all(self, q: GetAll = Depends(GetAll)):
|
def get_all(self, q: GetAll = Depends(GetAll)):
|
||||||
return self.repo.get_all(start=q.start, limit=q.limit)
|
return self.repo.get_all(start=q.start, limit=q.limit)
|
||||||
|
@ -95,6 +95,11 @@ class IngredientRequest(MealieModel):
|
|||||||
ingredient: str
|
ingredient: str
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientMerge(MealieModel):
|
||||||
|
from_food: UUID4
|
||||||
|
to_food: UUID4
|
||||||
|
|
||||||
|
|
||||||
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary
|
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary
|
||||||
|
|
||||||
IngredientFood.update_forward_refs()
|
IngredientFood.update_forward_refs()
|
||||||
|
18
poetry.lock
generated
18
poetry.lock
generated
@ -374,7 +374,7 @@ cli = ["requests"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.74.1"
|
version = "0.75.1"
|
||||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -387,8 +387,8 @@ starlette = "0.17.1"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
|
all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
|
||||||
dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
|
dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
|
||||||
doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"]
|
doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer (>=0.4.1,<0.5.0)", "pyyaml (>=5.3.1,<6.0.0)"]
|
||||||
test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"]
|
test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==22.3.0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
@ -1229,7 +1229,7 @@ rdflib = ">=5.0.0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "recipe-scrapers"
|
name = "recipe-scrapers"
|
||||||
version = "13.23.0"
|
version = "13.28.0"
|
||||||
description = "Python package, scraping recipes from all over the internet"
|
description = "Python package, scraping recipes from all over the internet"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -1599,7 +1599,7 @@ pgsql = ["psycopg2-binary"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.10"
|
python-versions = "^3.10"
|
||||||
content-hash = "7541b47452a32f483ab233daa846f07707a3d9da6f4e50c1285249639b1c40fd"
|
content-hash = "00c0adae74732437eaa473f24757191d620edfde671dceb5fdae28de9843d0c3"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
aiofiles = [
|
aiofiles = [
|
||||||
@ -1826,8 +1826,8 @@ extruct = [
|
|||||||
{file = "extruct-0.13.0.tar.gz", hash = "sha256:50a5b5bac4c5e19ecf682bf63a28fde0b1bb57433df7057371f60b58c94a2c64"},
|
{file = "extruct-0.13.0.tar.gz", hash = "sha256:50a5b5bac4c5e19ecf682bf63a28fde0b1bb57433df7057371f60b58c94a2c64"},
|
||||||
]
|
]
|
||||||
fastapi = [
|
fastapi = [
|
||||||
{file = "fastapi-0.74.1-py3-none-any.whl", hash = "sha256:b8ec8400623ef0b2ff558ebe06753b349f8e3a5dd38afea650800f2644ddba34"},
|
{file = "fastapi-0.75.1-py3-none-any.whl", hash = "sha256:f46f8fc81261c2bd956584114da9da98c84e2410c807bc2487532dabf55e7ab8"},
|
||||||
{file = "fastapi-0.74.1.tar.gz", hash = "sha256:b58a2c46df14f62ebe6f24a9439927539ba1959b9be55ba0e2f516a683e5b9d4"},
|
{file = "fastapi-0.75.1.tar.gz", hash = "sha256:8b62bde916d657803fb60fffe88e2b2c9fb854583784607e4347681cae20ad01"},
|
||||||
]
|
]
|
||||||
filelock = [
|
filelock = [
|
||||||
{file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"},
|
{file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"},
|
||||||
@ -2527,8 +2527,8 @@ rdflib-jsonld = [
|
|||||||
{file = "rdflib_jsonld-0.6.2-py2.py3-none-any.whl", hash = "sha256:011afe67672353ca9978ab9a4bee964dff91f14042f2d8a28c22a573779d2f8b"},
|
{file = "rdflib_jsonld-0.6.2-py2.py3-none-any.whl", hash = "sha256:011afe67672353ca9978ab9a4bee964dff91f14042f2d8a28c22a573779d2f8b"},
|
||||||
]
|
]
|
||||||
recipe-scrapers = [
|
recipe-scrapers = [
|
||||||
{file = "recipe_scrapers-13.23.0-py3-none-any.whl", hash = "sha256:120b356ca422e4f2afb8c944ecf2b53d3c9c73ac9f5345cf35bc168147056e17"},
|
{file = "recipe_scrapers-13.28.0-py3-none-any.whl", hash = "sha256:114ab8fb8baa85976f8709955baca4e6df07b565bfd5b60404eff89584d68e3f"},
|
||||||
{file = "recipe_scrapers-13.23.0.tar.gz", hash = "sha256:d99fbdaa1323e6d11e1378bfda0adc5536bd6acf3c71dc57380898300c577f45"},
|
{file = "recipe_scrapers-13.28.0.tar.gz", hash = "sha256:a12258f2218f8b222bdb57cf9d9d6b0288b892c258ccaec8efec02a292a8aded"},
|
||||||
]
|
]
|
||||||
requests = [
|
requests = [
|
||||||
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
|
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
|
||||||
|
@ -13,7 +13,7 @@ python = "^3.10"
|
|||||||
aiofiles = "0.5.0"
|
aiofiles = "0.5.0"
|
||||||
aniso8601 = "7.0.0"
|
aniso8601 = "7.0.0"
|
||||||
appdirs = "1.4.4"
|
appdirs = "1.4.4"
|
||||||
fastapi = "^0.74.1"
|
fastapi = "^0.75.1"
|
||||||
uvicorn = {extras = ["standard"], version = "^0.13.0"}
|
uvicorn = {extras = ["standard"], version = "^0.13.0"}
|
||||||
APScheduler = "^3.8.1"
|
APScheduler = "^3.8.1"
|
||||||
SQLAlchemy = "^1.4.29"
|
SQLAlchemy = "^1.4.29"
|
||||||
@ -31,7 +31,7 @@ passlib = "^1.7.4"
|
|||||||
lxml = "^4.7.1"
|
lxml = "^4.7.1"
|
||||||
Pillow = "^8.2.0"
|
Pillow = "^8.2.0"
|
||||||
apprise = "^0.9.6"
|
apprise = "^0.9.6"
|
||||||
recipe-scrapers = "^13.23.0"
|
recipe-scrapers = "^13.28.0"
|
||||||
psycopg2-binary = {version = "^2.9.1", optional = true}
|
psycopg2-binary = {version = "^2.9.1", optional = true}
|
||||||
gunicorn = "^20.1.0"
|
gunicorn = "^20.1.0"
|
||||||
emails = "^0.6"
|
emails = "^0.6"
|
||||||
|
48
tests/unit_tests/repository_tests/test_food_repository.py
Normal file
48
tests/unit_tests/repository_tests/test_food_repository.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
from mealie.repos.repository_factory import AllRepositories
|
||||||
|
from mealie.schema.recipe.recipe import Recipe
|
||||||
|
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientFood
|
||||||
|
from tests.utils.factories import random_string
|
||||||
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
||||||
|
def test_food_merger(database: AllRepositories, unique_user: TestUser):
|
||||||
|
slug1 = random_string(10)
|
||||||
|
|
||||||
|
food_1 = database.ingredient_foods.create(
|
||||||
|
SaveIngredientFood(
|
||||||
|
name=random_string(10),
|
||||||
|
group_id=unique_user.group_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
food_2 = database.ingredient_foods.create(
|
||||||
|
SaveIngredientFood(
|
||||||
|
name=random_string(10),
|
||||||
|
group_id=unique_user.group_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe = database.recipes.create(
|
||||||
|
Recipe(
|
||||||
|
name=slug1,
|
||||||
|
user_id=unique_user.group_id,
|
||||||
|
group_id=unique_user.group_id,
|
||||||
|
recipe_ingredient=[
|
||||||
|
RecipeIngredient(note="", food=food_1), # type: ignore
|
||||||
|
RecipeIngredient(note="", food=food_2), # type: ignore
|
||||||
|
],
|
||||||
|
) # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
# Santiy check make sure recipe got created
|
||||||
|
assert recipe.id is not None
|
||||||
|
|
||||||
|
for ing in recipe.recipe_ingredient:
|
||||||
|
assert ing.food.id in [food_1.id, food_2.id] # type: ignore
|
||||||
|
|
||||||
|
database.ingredient_foods.merge(food_2.id, food_1.id)
|
||||||
|
|
||||||
|
recipe = database.recipes.get_one(recipe.slug)
|
||||||
|
|
||||||
|
for ingredient in recipe.recipe_ingredient:
|
||||||
|
assert ingredient.food.id == food_1.id # type: ignore
|
Loading…
x
Reference in New Issue
Block a user