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:
Hayden 2022-04-09 19:08:48 -08:00 committed by GitHub
parent 10784b6e24
commit b93dae109e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 319 additions and 175 deletions

View File

@ -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
View File

@ -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"
]
} }

View File

@ -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.

View File

@ -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 });
}
} }

View File

@ -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>

View File

@ -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,
}; };
}, },
}); });

View File

@ -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>

View File

@ -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,
}; };
}, },
}); });

View File

@ -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>

View File

@ -98,15 +98,17 @@
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>
<UserProfileLinkCard <v-col cols="12" sm="12" md="6">
:link="{ text: 'Manage Your API Tokens', to: '/user/profile/api-tokens' }" <UserProfileLinkCard
:image="require('~/static/svgs/manage-api-tokens.svg')" :link="{ text: 'Manage Your API Tokens', to: '/user/profile/api-tokens' }"
> :image="require('~/static/svgs/manage-api-tokens.svg')"
<template #title> API Tokens </template> >
Manage your API Tokens for access from external applications <template #title> API Tokens </template>
</UserProfileLinkCard> Manage your API Tokens for access from external applications
</v-col> </UserProfileLinkCard>
</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,33 +145,50 @@
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>
<v-col v-if="user.advanced" cols="12" sm="12" md="6"> <AdvancedOnly>
<UserProfileLinkCard <v-col v-if="user.advanced" cols="12" sm="12" md="6">
:link="{ text: 'Manage Recipe Data', to: '/group/data/recipes' }" <UserProfileLinkCard
:image="require('~/static/svgs/manage-recipes.svg')" :link="{ text: 'Manage Webhooks', to: '/group/webhooks' }"
> :image="require('~/static/svgs/manage-webhooks.svg')"
<template #title> Recipe Data </template> >
Manage your recipe data and make bulk changes <template #title> Webhooks </template>
</UserProfileLinkCard> Setup webhooks that trigger on days that you have have mealplan scheduled.
</v-col> </UserProfileLinkCard>
<v-col v-if="user.advanced" cols="12" sm="12" md="6"> </v-col>
<UserProfileLinkCard </AdvancedOnly>
:link="{ text: 'Manage Data', to: '/group/data/foods' }" <AdvancedOnly>
:image="require('~/static/svgs/manage-recipes.svg')" <v-col cols="12" sm="12" md="6">
> <UserProfileLinkCard
<template #title> Manage Data </template> :link="{ text: 'Manage Notifiers', to: '/group/notifiers' }"
Manage your Food and Units (more options coming soon) :image="require('~/static/svgs/manage-notifiers.svg')"
</UserProfileLinkCard> >
</v-col> <template #title> Notifiers </template>
<v-col v-if="user.advanced" cols="12" sm="12" md="6"> Setup email and push notifications that trigger on specific events.
<UserProfileLinkCard </UserProfileLinkCard>
:link="{ text: 'Manage Data Migrations', to: '/group/data/migrations' }" </v-col>
:image="require('~/static/svgs/manage-data-migrations.svg')" </AdvancedOnly>
> <AdvancedOnly>
<template #title> Data Migrations </template> <v-col cols="12" sm="12" md="6">
Migrate your existing data from other applications like Nextcloud Recipes and Chowdown <UserProfileLinkCard
</UserProfileLinkCard> :link="{ text: 'Manage Data', to: '/group/data/foods' }"
</v-col> :image="require('~/static/svgs/manage-recipes.svg')"
>
<template #title> Manage Data </template>
Manage your Food and Units (more options coming soon)
</UserProfileLinkCard>
</v-col>
</AdvancedOnly>
<AdvancedOnly>
<v-col cols="12" sm="12" md="6">
<UserProfileLinkCard
:link="{ text: 'Manage Data Migrations', to: '/group/migrations' }"
:image="require('~/static/svgs/manage-data-migrations.svg')"
>
<template #title> Data Migrations </template>
Migrate your existing data from other applications like Nextcloud Recipes and Chowdown
</UserProfileLinkCard>
</v-col>
</AdvancedOnly>
</v-row> </v-row>
</section> </section>
</v-container> </v-container>

View File

@ -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.
*/ */

View File

@ -1,74 +1,76 @@
// This Code is auto generated by gen_global_components.py // This Code is auto generated by gen_global_components.py
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue"; import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
import MarkdownEditor from "@/components/global/MarkdownEditor.vue"; import MarkdownEditor from "@/components/global/MarkdownEditor.vue";
import AppLoader from "@/components/global/AppLoader.vue"; import AppLoader from "@/components/global/AppLoader.vue";
import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue"; import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
import ReportTable from "@/components/global/ReportTable.vue"; import ReportTable from "@/components/global/ReportTable.vue";
import AppToolbar from "@/components/global/AppToolbar.vue"; import AppToolbar from "@/components/global/AppToolbar.vue";
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue"; import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
import BaseButton from "@/components/global/BaseButton.vue"; import BaseButton from "@/components/global/BaseButton.vue";
import BannerExperimental from "@/components/global/BannerExperimental.vue"; import BannerExperimental from "@/components/global/BannerExperimental.vue";
import BaseDialog from "@/components/global/BaseDialog.vue"; import BaseDialog from "@/components/global/BaseDialog.vue";
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue"; import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
import StatsCards from "@/components/global/StatsCards.vue"; import StatsCards from "@/components/global/StatsCards.vue";
import HelpIcon from "@/components/global/HelpIcon.vue"; import HelpIcon from "@/components/global/HelpIcon.vue";
import InputLabelType from "@/components/global/InputLabelType.vue"; import InputLabelType from "@/components/global/InputLabelType.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue"; import BaseStatCard from "@/components/global/BaseStatCard.vue";
import DevDumpJson from "@/components/global/DevDumpJson.vue"; import DevDumpJson from "@/components/global/DevDumpJson.vue";
import LanguageDialog from "@/components/global/LanguageDialog.vue"; import LanguageDialog from "@/components/global/LanguageDialog.vue";
import InputQuantity from "@/components/global/InputQuantity.vue"; import InputQuantity from "@/components/global/InputQuantity.vue";
import ToggleState from "@/components/global/ToggleState.vue"; import ToggleState from "@/components/global/ToggleState.vue";
import AppButtonCopy from "@/components/global/AppButtonCopy.vue"; import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
import CrudTable from "@/components/global/CrudTable.vue"; import CrudTable from "@/components/global/CrudTable.vue";
import InputColor from "@/components/global/InputColor.vue"; import InputColor from "@/components/global/InputColor.vue";
import BaseDivider from "@/components/global/BaseDivider.vue"; import BaseDivider from "@/components/global/BaseDivider.vue";
import AutoForm from "@/components/global/AutoForm.vue"; import AutoForm from "@/components/global/AutoForm.vue";
import AppButtonUpload from "@/components/global/AppButtonUpload.vue"; import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
import AdvancedOnly from "@/components/global/AdvancedOnly.vue"; import AdvancedOnly from "@/components/global/AdvancedOnly.vue";
import BasePageTitle from "@/components/global/BasePageTitle.vue"; import BasePageTitle from "@/components/global/BasePageTitle.vue";
import ButtonLink from "@/components/global/ButtonLink.vue"; import ButtonLink from "@/components/global/ButtonLink.vue";
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
import AppHeader from "@/components/layout/AppHeader.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import AppFooter from "@/components/layout/AppFooter.vue";
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
import AppHeader from "@/components/layout/AppHeader.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import AppFooter from "@/components/layout/AppFooter.vue";
declare module "vue" { declare module "vue" {
export interface GlobalComponents { export interface GlobalComponents {
// Global Components // Global Components
BaseCardSectionTitle: typeof BaseCardSectionTitle; BaseCardSectionTitle: typeof BaseCardSectionTitle;
MarkdownEditor: typeof MarkdownEditor; MarkdownEditor: typeof MarkdownEditor;
AppLoader: typeof AppLoader; AppLoader: typeof AppLoader;
BaseOverflowButton: typeof BaseOverflowButton; BaseOverflowButton: typeof BaseOverflowButton;
ReportTable: typeof ReportTable; ReportTable: typeof ReportTable;
AppToolbar: typeof AppToolbar; AppToolbar: typeof AppToolbar;
BaseButtonGroup: typeof BaseButtonGroup; BaseButtonGroup: typeof BaseButtonGroup;
BaseButton: typeof BaseButton; BaseButton: typeof BaseButton;
BannerExperimental: typeof BannerExperimental; BannerExperimental: typeof BannerExperimental;
BaseDialog: typeof BaseDialog; BaseDialog: typeof BaseDialog;
RecipeJsonEditor: typeof RecipeJsonEditor; RecipeJsonEditor: typeof RecipeJsonEditor;
StatsCards: typeof StatsCards; StatsCards: typeof StatsCards;
HelpIcon: typeof HelpIcon; HelpIcon: typeof HelpIcon;
InputLabelType: typeof InputLabelType; InputLabelType: typeof InputLabelType;
BaseStatCard: typeof BaseStatCard; BaseStatCard: typeof BaseStatCard;
DevDumpJson: typeof DevDumpJson; DevDumpJson: typeof DevDumpJson;
LanguageDialog: typeof LanguageDialog; LanguageDialog: typeof LanguageDialog;
InputQuantity: typeof InputQuantity; InputQuantity: typeof InputQuantity;
ToggleState: typeof ToggleState; ToggleState: typeof ToggleState;
AppButtonCopy: typeof AppButtonCopy; AppButtonCopy: typeof AppButtonCopy;
CrudTable: typeof CrudTable; CrudTable: typeof CrudTable;
InputColor: typeof InputColor; InputColor: typeof InputColor;
BaseDivider: typeof BaseDivider; BaseDivider: typeof BaseDivider;
AutoForm: typeof AutoForm; AutoForm: typeof AutoForm;
AppButtonUpload: typeof AppButtonUpload; AppButtonUpload: typeof AppButtonUpload;
AdvancedOnly: typeof AdvancedOnly; AdvancedOnly: typeof AdvancedOnly;
BasePageTitle: typeof BasePageTitle; BasePageTitle: typeof BasePageTitle;
ButtonLink: typeof ButtonLink; ButtonLink: typeof ButtonLink;
// Layout Components // Layout Components
TheSnackbar: typeof TheSnackbar; TheSnackbar: typeof TheSnackbar;
AppHeader: typeof AppHeader; AppHeader: typeof AppHeader;
AppSidebar: typeof AppSidebar; AppSidebar: typeof AppSidebar;
AppFooter: typeof AppFooter; AppFooter: typeof AppFooter;
} }
} }

View File

@ -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]:

View 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

View File

@ -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)

View File

@ -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
View File

@ -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"},

View File

@ -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"

View 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