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
ports:
- 5432:5432
# Steps
steps:
#----------------------------------------------
@ -70,6 +69,7 @@ jobs:
poetry install
poetry add "psycopg2-binary==2.8.6"
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
#----------------------------------------------
# run test suite
#----------------------------------------------

34
.vscode/settings.json vendored
View file

@ -5,11 +5,7 @@
"backend",
"code-generation"
],
"cSpell.enableFiletypes": [
"!javascript",
"!python",
"!yaml"
],
"cSpell.enableFiletypes": ["!javascript", "!python", "!yaml"],
"cSpell.words": [
"chowdown",
"compression",
@ -24,9 +20,7 @@
"source.organizeImports": false
},
"editor.formatOnSave": true,
"eslint.workingDirectories": [
"./frontend"
],
"eslint.workingDirectories": ["./frontend"],
"files.exclude": {
"**/__pycache__": true,
"**/.DS_Store": true,
@ -35,9 +29,7 @@
"**/.svn": true,
"**/CVS": true
},
"i18n-ally.enabledFrameworks": [
"vue"
],
"i18n-ally.enabledFrameworks": ["vue"],
"i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": "frontend/lang/messages",
"i18n-ally.sourceLanguage": "en-US",
@ -45,26 +37,14 @@
"python.linting.enabled": true,
"python.linting.flake8Enabled": true,
"python.linting.pylintEnabled": false,
"python.linting.pylintArgs": [
"--rcfile=${workspaceFolder}/.pylintrc"
],
"python.linting.pylintArgs": ["--rcfile=${workspaceFolder}/.pylintrc"],
"python.testing.autoTestDiscoverOnSaveEnabled": false,
"python.testing.pytestArgs": [
"tests"
],
"python.testing.pytestArgs": ["tests"],
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.analysis.typeCheckingMode": "basic",
"python.analysis.typeCheckingMode": "off",
"python.linting.mypyEnabled": true,
"python.sortImports.path": "${workspaceFolder}/.venv/bin/isort",
"search.mode": "reuseEditor",
"vetur.validation.template": false,
"coverage-gutters.lcovname": "${workspaceFolder}/.coverage",
"python.testing.unittestArgs": [
"-v",
"-s",
"./tests",
"-p",
"test_*.py"
]
"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
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.

View file

@ -6,9 +6,15 @@ const prefix = "/api";
const routes = {
food: `${prefix}/foods`,
foodsFood: (tag: string) => `${prefix}/foods/${tag}`,
merge: `${prefix}/foods/merge`,
};
export class FoodAPI extends BaseCRUDAPI<IngredientFood, CreateIngredientFood> {
baseRoute: string = routes.food;
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) {
router.push("/group/data/reports/" + item.id);
router.push("/group/reports/" + item.id);
}
function capitalize(str: string) {
@ -69,5 +69,4 @@ export default defineComponent({
});
</script>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>

View file

@ -6,7 +6,8 @@
:top-link="topLinks"
:bottom-links="bottomLinks"
:user="{ data: true }"
:secondary-header="$t('user.admin')"
secondary-header="Developer"
:secondary-links="developerLinks"
/>
<TheSnackbar />
@ -49,11 +50,7 @@ export default defineComponent({
to: "/admin/site-settings",
title: i18n.t("sidebar.site-settings"),
},
{
icon: $globals.icons.wrench,
to: "/admin/maintenance",
title: "Maintenance",
},
// {
// icon: $globals.icons.chart,
// to: "/admin/analytics",
@ -74,6 +71,14 @@ export default defineComponent({
to: "/admin/backups",
title: i18n.t("sidebar.backups"),
},
];
const developerLinks: SidebarLinks = [
{
icon: $globals.icons.wrench,
to: "/admin/maintenance",
title: "Maintenance",
},
{
icon: $globals.icons.check,
to: "/admin/background-tasks",
@ -98,6 +103,7 @@ export default defineComponent({
sidebar,
topLinks,
bottomLinks,
developerLinks,
};
},
});

View file

@ -97,7 +97,7 @@
</section>
</section>
<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>
</template>

View file

@ -1,5 +1,20 @@
<template>
<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 -->
<BaseDialog
v-model="editDialog"
@ -48,7 +63,7 @@
@edit-one="editEventHandler"
>
<template #button-row>
<BaseButton :disabled="true">
<BaseButton @click="mergeDialog = true">
<template #icon> {{ $globals.icons.foods }} </template>
Combine
</BaseButton>
@ -64,6 +79,7 @@
<script lang="ts">
import { defineComponent, onMounted, ref } from "@nuxtjs/composition-api";
import { computed } from "vue-demi";
import { validators } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
import { IngredientFood } from "~/types/api-types/recipe";
@ -144,6 +160,29 @@ export default defineComponent({
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
@ -170,6 +209,12 @@ export default defineComponent({
deleteEventHandler,
deleteDialog,
deleteFood,
// Merge
canMerge,
mergeFoods,
mergeDialog,
fromFood,
toFood,
};
},
});

View file

@ -312,7 +312,7 @@
<AdvancedOnly>
<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>
</AdvancedOnly>
</div>

View file

@ -98,15 +98,17 @@
Manage your preferences, change your password, and update your email
</UserProfileLinkCard>
</v-col>
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
<UserProfileLinkCard
: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
</UserProfileLinkCard>
</v-col>
<AdvancedOnly>
<v-col cols="12" sm="12" md="6">
<UserProfileLinkCard
: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
</UserProfileLinkCard>
</v-col>
</AdvancedOnly>
</v-row>
</section>
<v-divider class="my-7"></v-divider>
@ -134,24 +136,6 @@
Manage a collection of recipe categories and generate pages for them.
</UserProfileLinkCard>
</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">
<UserProfileLinkCard
:link="{ text: 'Manage Members', to: '/group/members' }"
@ -161,33 +145,50 @@
See who's in your group and manage their permissions.
</UserProfileLinkCard>
</v-col>
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
<UserProfileLinkCard
:link="{ text: 'Manage Recipe Data', to: '/group/data/recipes' }"
:image="require('~/static/svgs/manage-recipes.svg')"
>
<template #title> Recipe Data </template>
Manage your recipe data and make bulk changes
</UserProfileLinkCard>
</v-col>
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
<UserProfileLinkCard
:link="{ text: 'Manage Data', to: '/group/data/foods' }"
:image="require('~/static/svgs/manage-recipes.svg')"
>
<template #title> Manage Data </template>
Manage your Food and Units (more options coming soon)
</UserProfileLinkCard>
</v-col>
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
<UserProfileLinkCard
:link="{ text: 'Manage Data Migrations', to: '/group/data/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-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>
</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
:link="{ text: 'Manage Data', to: '/group/data/foods' }"
: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>
</section>
</v-container>

View file

@ -113,6 +113,10 @@ export interface MultiPurposeLabelSummary {
groupId: string;
id: string;
}
export interface IngredientMerge {
fromFood: string;
toFood: string;
}
/**
* A list of ingredient references.
*/

View file

@ -1,74 +1,76 @@
// This Code is auto generated by gen_global_components.py
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
import MarkdownEditor from "@/components/global/MarkdownEditor.vue";
import AppLoader from "@/components/global/AppLoader.vue";
import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
import ReportTable from "@/components/global/ReportTable.vue";
import AppToolbar from "@/components/global/AppToolbar.vue";
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
import BaseButton from "@/components/global/BaseButton.vue";
import BannerExperimental from "@/components/global/BannerExperimental.vue";
import BaseDialog from "@/components/global/BaseDialog.vue";
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
import StatsCards from "@/components/global/StatsCards.vue";
import HelpIcon from "@/components/global/HelpIcon.vue";
import InputLabelType from "@/components/global/InputLabelType.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue";
import DevDumpJson from "@/components/global/DevDumpJson.vue";
import LanguageDialog from "@/components/global/LanguageDialog.vue";
import InputQuantity from "@/components/global/InputQuantity.vue";
import ToggleState from "@/components/global/ToggleState.vue";
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
import CrudTable from "@/components/global/CrudTable.vue";
import InputColor from "@/components/global/InputColor.vue";
import BaseDivider from "@/components/global/BaseDivider.vue";
import AutoForm from "@/components/global/AutoForm.vue";
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
import AdvancedOnly from "@/components/global/AdvancedOnly.vue";
import BasePageTitle from "@/components/global/BasePageTitle.vue";
import ButtonLink from "@/components/global/ButtonLink.vue";
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
import MarkdownEditor from "@/components/global/MarkdownEditor.vue";
import AppLoader from "@/components/global/AppLoader.vue";
import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
import ReportTable from "@/components/global/ReportTable.vue";
import AppToolbar from "@/components/global/AppToolbar.vue";
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
import BaseButton from "@/components/global/BaseButton.vue";
import BannerExperimental from "@/components/global/BannerExperimental.vue";
import BaseDialog from "@/components/global/BaseDialog.vue";
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
import StatsCards from "@/components/global/StatsCards.vue";
import HelpIcon from "@/components/global/HelpIcon.vue";
import InputLabelType from "@/components/global/InputLabelType.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue";
import DevDumpJson from "@/components/global/DevDumpJson.vue";
import LanguageDialog from "@/components/global/LanguageDialog.vue";
import InputQuantity from "@/components/global/InputQuantity.vue";
import ToggleState from "@/components/global/ToggleState.vue";
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
import CrudTable from "@/components/global/CrudTable.vue";
import InputColor from "@/components/global/InputColor.vue";
import BaseDivider from "@/components/global/BaseDivider.vue";
import AutoForm from "@/components/global/AutoForm.vue";
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
import AdvancedOnly from "@/components/global/AdvancedOnly.vue";
import BasePageTitle from "@/components/global/BasePageTitle.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" {
export interface GlobalComponents {
// Global Components
BaseCardSectionTitle: typeof BaseCardSectionTitle;
MarkdownEditor: typeof MarkdownEditor;
AppLoader: typeof AppLoader;
BaseOverflowButton: typeof BaseOverflowButton;
ReportTable: typeof ReportTable;
AppToolbar: typeof AppToolbar;
BaseButtonGroup: typeof BaseButtonGroup;
BaseButton: typeof BaseButton;
BannerExperimental: typeof BannerExperimental;
BaseDialog: typeof BaseDialog;
RecipeJsonEditor: typeof RecipeJsonEditor;
StatsCards: typeof StatsCards;
HelpIcon: typeof HelpIcon;
InputLabelType: typeof InputLabelType;
BaseStatCard: typeof BaseStatCard;
DevDumpJson: typeof DevDumpJson;
LanguageDialog: typeof LanguageDialog;
InputQuantity: typeof InputQuantity;
ToggleState: typeof ToggleState;
AppButtonCopy: typeof AppButtonCopy;
CrudTable: typeof CrudTable;
InputColor: typeof InputColor;
BaseDivider: typeof BaseDivider;
AutoForm: typeof AutoForm;
AppButtonUpload: typeof AppButtonUpload;
AdvancedOnly: typeof AdvancedOnly;
BasePageTitle: typeof BasePageTitle;
ButtonLink: typeof ButtonLink;
// Layout Components
TheSnackbar: typeof TheSnackbar;
AppHeader: typeof AppHeader;
AppSidebar: typeof AppSidebar;
AppFooter: typeof AppFooter;
BaseCardSectionTitle: typeof BaseCardSectionTitle;
MarkdownEditor: typeof MarkdownEditor;
AppLoader: typeof AppLoader;
BaseOverflowButton: typeof BaseOverflowButton;
ReportTable: typeof ReportTable;
AppToolbar: typeof AppToolbar;
BaseButtonGroup: typeof BaseButtonGroup;
BaseButton: typeof BaseButton;
BannerExperimental: typeof BannerExperimental;
BaseDialog: typeof BaseDialog;
RecipeJsonEditor: typeof RecipeJsonEditor;
StatsCards: typeof StatsCards;
HelpIcon: typeof HelpIcon;
InputLabelType: typeof InputLabelType;
BaseStatCard: typeof BaseStatCard;
DevDumpJson: typeof DevDumpJson;
LanguageDialog: typeof LanguageDialog;
InputQuantity: typeof InputQuantity;
ToggleState: typeof ToggleState;
AppButtonCopy: typeof AppButtonCopy;
CrudTable: typeof CrudTable;
InputColor: typeof InputColor;
BaseDivider: typeof BaseDivider;
AutoForm: typeof AutoForm;
AppButtonUpload: typeof AppButtonUpload;
AdvancedOnly: typeof AdvancedOnly;
BasePageTitle: typeof BasePageTitle;
ButtonLink: typeof ButtonLink;
// Layout Components
TheSnackbar: typeof TheSnackbar;
AppHeader: typeof AppHeader;
AppSidebar: typeof AppSidebar;
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.users import LongLiveToken, User
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.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.group.group_events import GroupEventNotifierOut
@ -94,8 +95,8 @@ class AllRepositories:
return RepositoryRecipes(self.session, PK_SLUG, RecipeModel, Recipe)
@cached_property
def ingredient_foods(self) -> RepositoryGeneric[IngredientFood, IngredientFoodModel]:
return RepositoryGeneric(self.session, PK_ID, IngredientFoodModel, IngredientFood)
def ingredient_foods(self) -> RepositoryFood:
return RepositoryFood(self.session, PK_ID, IngredientFoodModel, IngredientFood)
@cached_property
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 fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
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.schema import mapper
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"])
@ -27,6 +33,15 @@ class IngredientFoodsController(BaseUserController):
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])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit)

View file

@ -95,6 +95,11 @@ class IngredientRequest(MealieModel):
ingredient: str
class IngredientMerge(MealieModel):
from_food: UUID4
to_food: UUID4
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary
IngredientFood.update_forward_refs()

18
poetry.lock generated
View file

@ -374,7 +374,7 @@ cli = ["requests"]
[[package]]
name = "fastapi"
version = "0.74.1"
version = "0.75.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
category = "main"
optional = false
@ -387,8 +387,8 @@ starlette = "0.17.1"
[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)"]
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)"]
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)"]
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 (==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]]
name = "filelock"
@ -1229,7 +1229,7 @@ rdflib = ">=5.0.0"
[[package]]
name = "recipe-scrapers"
version = "13.23.0"
version = "13.28.0"
description = "Python package, scraping recipes from all over the internet"
category = "main"
optional = false
@ -1599,7 +1599,7 @@ pgsql = ["psycopg2-binary"]
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "7541b47452a32f483ab233daa846f07707a3d9da6f4e50c1285249639b1c40fd"
content-hash = "00c0adae74732437eaa473f24757191d620edfde671dceb5fdae28de9843d0c3"
[metadata.files]
aiofiles = [
@ -1826,8 +1826,8 @@ extruct = [
{file = "extruct-0.13.0.tar.gz", hash = "sha256:50a5b5bac4c5e19ecf682bf63a28fde0b1bb57433df7057371f60b58c94a2c64"},
]
fastapi = [
{file = "fastapi-0.74.1-py3-none-any.whl", hash = "sha256:b8ec8400623ef0b2ff558ebe06753b349f8e3a5dd38afea650800f2644ddba34"},
{file = "fastapi-0.74.1.tar.gz", hash = "sha256:b58a2c46df14f62ebe6f24a9439927539ba1959b9be55ba0e2f516a683e5b9d4"},
{file = "fastapi-0.75.1-py3-none-any.whl", hash = "sha256:f46f8fc81261c2bd956584114da9da98c84e2410c807bc2487532dabf55e7ab8"},
{file = "fastapi-0.75.1.tar.gz", hash = "sha256:8b62bde916d657803fb60fffe88e2b2c9fb854583784607e4347681cae20ad01"},
]
filelock = [
{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"},
]
recipe-scrapers = [
{file = "recipe_scrapers-13.23.0-py3-none-any.whl", hash = "sha256:120b356ca422e4f2afb8c944ecf2b53d3c9c73ac9f5345cf35bc168147056e17"},
{file = "recipe_scrapers-13.23.0.tar.gz", hash = "sha256:d99fbdaa1323e6d11e1378bfda0adc5536bd6acf3c71dc57380898300c577f45"},
{file = "recipe_scrapers-13.28.0-py3-none-any.whl", hash = "sha256:114ab8fb8baa85976f8709955baca4e6df07b565bfd5b60404eff89584d68e3f"},
{file = "recipe_scrapers-13.28.0.tar.gz", hash = "sha256:a12258f2218f8b222bdb57cf9d9d6b0288b892c258ccaec8efec02a292a8aded"},
]
requests = [
{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"
aniso8601 = "7.0.0"
appdirs = "1.4.4"
fastapi = "^0.74.1"
fastapi = "^0.75.1"
uvicorn = {extras = ["standard"], version = "^0.13.0"}
APScheduler = "^3.8.1"
SQLAlchemy = "^1.4.29"
@ -31,7 +31,7 @@ passlib = "^1.7.4"
lxml = "^4.7.1"
Pillow = "^8.2.0"
apprise = "^0.9.6"
recipe-scrapers = "^13.23.0"
recipe-scrapers = "^13.28.0"
psycopg2-binary = {version = "^2.9.1", optional = true}
gunicorn = "^20.1.0"
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