Add Database Layer for Recipe Scaling (#506)

* move badge

* fix add individual ingredient

* fix redirect issue

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-06-12 22:23:23 -08:00 committed by GitHub
parent 0a927afaa0
commit e95ca870b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 298 additions and 62 deletions

View File

@ -31,6 +31,7 @@ const apiReq = {
post: async function(url, data, getErrorText = defaultErrorText, getSuccessText) {
const response = await axios.post(url, data).catch(function(error) {
handleError(error, getErrorText);
return error;
});
return handleResponse(response, getSuccessText);
},
@ -38,6 +39,7 @@ const apiReq = {
put: async function(url, data, getErrorText = defaultErrorText, getSuccessText) {
const response = await axios.put(url, data).catch(function(error) {
handleError(error, getErrorText);
return error;
});
return handleResponse(response, getSuccessText);
},
@ -45,6 +47,7 @@ const apiReq = {
patch: async function(url, data, getErrorText = defaultErrorText, getSuccessText) {
const response = await axios.patch(url, data).catch(function(error) {
handleError(error, getErrorText);
return error;
});
return handleResponse(response, getSuccessText);
},
@ -52,12 +55,14 @@ const apiReq = {
get: async function(url, data, getErrorText = defaultErrorText) {
return axios.get(url, data).catch(function(error) {
handleError(error, getErrorText);
return error;
});
},
delete: async function(url, data, getErrorText = defaultErrorText, getSuccessText = defaultSuccessText) {
const response = await axios.delete(url, data).catch(function(error) {
handleError(error, getErrorText);
return error;
});
return handleResponse(response, getSuccessText);
},

View File

@ -35,9 +35,11 @@ export const recipeAPI = {
},
async requestDetails(recipeSlug) {
let response = await apiReq.get(API_ROUTES.recipesRecipeSlug(recipeSlug));
if (response && response.data) return response.data;
else return null;
const response = await apiReq.get(API_ROUTES.recipesRecipeSlug(recipeSlug));
if (response.response) {
return response.response;
}
return response;
},
updateImage(recipeSlug, fileObject, overrideSuccessMsg = false) {

View File

@ -16,25 +16,13 @@
:top="menuTop"
:nudge-top="menuTop ? '5' : '0'"
allow-overflow
close-delay="125"
open-on-hover
>
<template v-slot:activator="{ on: onMenu, attrs: attrsMenu }">
<v-tooltip bottom dark :color="color">
<template v-slot:activator="{ on: onTooltip, attrs: attrsTooltip }">
<v-btn
:fab="fab"
:small="fab"
:color="color"
:icon="!fab"
dark
v-bind="{ ...attrsMenu, ...attrsTooltip }"
v-on="{ ...onMenu, ...onTooltip }"
@click.prevent
>
<v-icon>{{ menuIcon }}</v-icon>
</v-btn>
</template>
<span>{{ $t("general.more") }}</span>
</v-tooltip>
<template v-slot:activator="{ on, attrs }">
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent>
<v-icon>{{ menuIcon }}</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item

View File

@ -1,5 +1,5 @@
<template>
<v-tooltip right :color="buttonStyle ? 'primary' : 'secondary'">
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'primary' : 'secondary'">
<template v-slot:activator="{ on, attrs }">
<v-btn
small

View File

@ -1,5 +1,5 @@
<template>
<div>
<div v-if="value && value.length > 0">
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
<div v-if="edit">
<draggable :value="value" @input="updateIndex" @start="drag = true" @end="drag = false" handle=".handle">
@ -9,7 +9,7 @@
<v-textarea
class="mr-2"
:label="$t('recipe.ingredient')"
v-model="value[index]"
v-model="value[index].note"
mdi-move-resize
auto-grow
solo
@ -45,7 +45,7 @@
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary"> </v-checkbox>
<v-list-item-content>
<vue-markdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredient"> </vue-markdown>
<vue-markdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredient.note"> </vue-markdown>
</v-list-item-content>
</v-list-item>
</div>
@ -85,9 +85,26 @@ export default {
methods: {
addIngredient(ingredients = null) {
if (ingredients.length) {
this.value.push(...ingredients);
const newIngredients = ingredients.map(x => {
return {
title: null,
note: x,
unit: null,
food: null,
disableAmount: true,
quantity: 1,
};
});
this.value.push(...newIngredients);
} else {
this.value.push("");
this.value.push({
title: null,
note: "",
unit: null,
food: null,
disableAmount: true,
quantity: 1,
});
}
},
generateKey(item, index) {

View File

@ -192,8 +192,12 @@ export default {
return;
}
this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe);
if (!this.recipeDetails) router.push(`/login`);
const response = await api.recipes.requestDetails(this.currentRecipe);
console.log("View Response", { response });
if (response.status === 401) router.push(`/login`);
if (response.status === 404) return;
this.recipeDetails = response.data;
this.skeleton = false;
},
getImage(slug) {

View File

@ -5,18 +5,19 @@
<v-icon left>
mdi-arrow-left-bold
</v-icon>
{{$t('shopping-list.all-lists')}}
{{ $t("shopping-list.all-lists") }}
</v-btn>
<v-icon v-if="!list" large left>
mdi-format-list-checks
</v-icon>
<v-toolbar-title v-if="!list" class="headline"> {{$t('shopping-list.shopping-lists')}} </v-toolbar-title>
<v-toolbar-title v-if="!list" class="headline"> {{ $t("shopping-list.shopping-lists") }} </v-toolbar-title>
<v-spacer></v-spacer>
<BaseDialog
:title="$t('shopping-list.new-list')"
title-icon="mdi-format-list-checks"
:submit-text="$t('general.create')"
@submit="createNewList">
<BaseDialog
:title="$t('shopping-list.new-list')"
title-icon="mdi-format-list-checks"
:submit-text="$t('general.create')"
@submit="createNewList"
>
<template v-slot:open="{ open }">
<TheButton create @click="open" />
</template>
@ -41,7 +42,7 @@
<v-icon left>
mdi-cart-check
</v-icon>
{{$t('general.view')}}
{{ $t("general.view") }}
</v-btn>
</v-card-actions>
</v-card>
@ -65,7 +66,7 @@
<v-card-text>
<v-row dense v-for="(item, index) in activeList.items" :key="index">
<v-col v-if="edit" cols="12" class="d-flex no-wrap align-center">
<p class="mb-0">{{$t('shopping-list.quantity', [item.quantity])}}</p>
<p class="mb-0">{{ $t("shopping-list.quantity", [item.quantity]) }}</p>
<div v-if="edit">
<v-btn x-small text class="ml-1" @click="activeList.items[index].quantity -= 1">
<v-icon>
@ -123,13 +124,13 @@
<v-icon left>
{{ $globals.icons.primary }}
</v-icon>
{{$t('shopping-list.from-recipe')}}
{{ $t("shopping-list.from-recipe") }}
</v-btn>
<v-btn v-if="edit" color="success" @click="newItem">
<v-icon left>
{{ $globals.icons.create }}
</v-icon>
{{$t('general.new')}}
{{ $t("general.new") }}
</v-btn>
</v-card-actions>
</v-card>
@ -197,7 +198,8 @@ export default {
this.$refs.searchRecipe.open();
},
async importIngredients(selected) {
const recipe = await api.recipes.requestDetails(selected.slug);
const response = await api.recipes.requestDetails(selected.slug);
const recipe = response.data;
const ingredients = recipe.recipeIngredient.map(x => ({
title: "",

View File

@ -26,7 +26,8 @@ export const recipeRoutes = [
component: ViewRecipe,
meta: {
title: async route => {
const recipe = await api.recipes.requestDetails(route.params.recipe);
const response = await api.recipes.requestDetails(route.params.recipe);
const recipe = response.data;
if (recipe && recipe.name) return recipe.name;
else return null;
},

View File

@ -23,7 +23,7 @@ BROWSER := python -c "$$BROWSER_PYSCRIPT"
help:
@python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
clean-purge: clean ## ⚠️ Removes All Developer Data for a fresh server start
purge: clean ## ⚠️ Removes All Developer Data for a fresh server start
rm -r ./dev/data/recipes/
rm -r ./dev/data/users/
rm -f ./dev/data/mealie_v*.db

View File

@ -6,6 +6,7 @@ from mealie.db.models.event import Event, EventNotification
from mealie.db.models.group import Group
from mealie.db.models.mealplan import MealPlan
from mealie.db.models.recipe.comment import RecipeComment
from mealie.db.models.recipe.ingredient import IngredientFood, IngredientUnit
from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag
from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.settings import CustomPage, SiteSettings
@ -18,7 +19,8 @@ from mealie.schema.comments import CommentOut
from mealie.schema.event_notifications import EventNotificationIn
from mealie.schema.events import Event as EventSchema
from mealie.schema.meal import MealPlanOut
from mealie.schema.recipe import Recipe
from mealie.schema.recipe import (Recipe, RecipeIngredientFood,
RecipeIngredientUnit)
from mealie.schema.settings import CustomPageOut
from mealie.schema.settings import SiteSettings as SiteSettingsSchema
from mealie.schema.shopping_list import ShoppingListOut
@ -87,6 +89,20 @@ class _Recipes(BaseDocument):
)
class _IngredientFoods(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = IngredientFood
self.schema = RecipeIngredientFood
class _IngredientUnits(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = IngredientUnit
self.schema = RecipeIngredientUnit
class _Categories(BaseDocument):
def __init__(self) -> None:
self.primary_key = "slug"
@ -215,21 +231,28 @@ class _EventNotification(BaseDocument):
class Database:
def __init__(self) -> None:
# Recipes
self.recipes = _Recipes()
self.meals = _Meals()
self.settings = _Settings()
self.themes = _Themes()
self.ingredient_foods = _IngredientUnits()
self.ingredient_units = _IngredientFoods()
self.categories = _Categories()
self.tags = _Tags()
self.comments = _Comments()
# Site
self.settings = _Settings()
self.themes = _Themes()
self.sign_ups = _SignUps()
self.custom_pages = _CustomPages()
self.event_notifications = _EventNotification()
self.events = _Events()
# Users / Groups
self.users = _Users()
self.api_tokens = _LongLiveToken()
self.sign_ups = _SignUps()
self.groups = _Groups()
self.custom_pages = _CustomPages()
self.events = _Events()
self.event_notifications = _EventNotification()
self.meals = _Meals()
self.shopping_lists = _ShoppingList()
self.comments = _Comments()
db = Database()

View File

@ -1,5 +1,70 @@
from mealie.db.models.model_base import SqlAlchemyBase
from sqlalchemy import Column, ForeignKey, Integer, String
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from requests import Session
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Table, orm
ingredients_to_units = Table(
"ingredients_to_units",
SqlAlchemyBase.metadata,
Column("ingredient_units.id", Integer, ForeignKey("ingredient_units.id")),
Column("recipes_ingredients_id", Integer, ForeignKey("recipes_ingredients.id")),
)
ingredients_to_foods = Table(
"ingredients_to_foods",
SqlAlchemyBase.metadata,
Column("ingredient_foods.id", Integer, ForeignKey("ingredient_foods.id")),
Column("recipes_ingredients_id", Integer, ForeignKey("recipes_ingredients.id")),
)
class IngredientUnit(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_units"
id = Column(Integer, primary_key=True)
name = Column(String)
description = Column(String)
ingredients = orm.relationship("RecipeIngredient", secondary=ingredients_to_units, back_populates="unit")
def __init__(self, name: str, description: str = None) -> None:
self.name = name
self.description = description
@classmethod
def get_ref_or_create(cls, session: Session, obj: dict):
# sourcery skip: flip-comparison
if obj is None:
return None
name = obj.get("name")
unit = session.query(cls).filter("name" == name).one_or_none()
if not unit:
return cls(**obj)
class IngredientFood(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_foods"
id = Column(Integer, primary_key=True)
name = Column(String)
description = Column(String)
ingredients = orm.relationship("RecipeIngredient", secondary=ingredients_to_foods, back_populates="food")
def __init__(self, name: str, description: str = None) -> None:
self.name = name
self.description = description
@classmethod
def get_ref_or_create(cls, session: Session, obj: dict):
# sourcery skip: flip-comparison
if obj is None:
return None
name = obj.get("name")
unit = session.query(cls).filter("name" == name).one_or_none()
if not unit:
return cls(**obj)
class RecipeIngredient(SqlAlchemyBase):
@ -7,8 +72,24 @@ class RecipeIngredient(SqlAlchemyBase):
id = Column(Integer, primary_key=True)
position = Column(Integer)
parent_id = Column(Integer, ForeignKey("recipes.id"))
# title = Column(String)
ingredient = Column(String)
def update(self, ingredient):
self.ingredient = ingredient
title = Column(String) # Section Header - Shows if Present
note = Column(String) # Force Show Text - Overrides Concat
# Scaling Items
unit = orm.relationship(IngredientUnit, secondary=ingredients_to_units, uselist=False)
food = orm.relationship(IngredientFood, secondary=ingredients_to_foods, uselist=False)
quantity = Column(Integer)
# Extras
disable_amount = Column(Boolean, default=False)
def __init__(
self, title: str, note: str, unit: dict, food: dict, quantity: int, disable_amount: bool, session: Session, **_
) -> None:
self.title = title
self.note = note
self.unit = IngredientUnit.get_ref_or_create(session, unit)
self.food = IngredientFood.get_ref_or_create(session, food)
self.quantity = quantity
self.disable_amount = disable_amount

View File

@ -117,7 +117,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
self.tools = [Tool(tool=x) for x in tools] if tools else []
self.recipe_yield = recipe_yield
self.recipe_ingredient = [RecipeIngredient(ingredient=ingr) for ingr in recipe_ingredient]
self.recipe_ingredient = [RecipeIngredient(**ingr, session=session) for ingr in recipe_ingredient]
self.assets = [RecipeAsset(**a) for a in assets]
self.recipe_instructions = [
RecipeInstruction(text=instruc.get("text"), title=instruc.get("title"), type=instruc.get("@type", None))

View File

@ -37,7 +37,7 @@ def get_shopping_list(
logger.error("Recipe Not Found")
new_list = ShoppingListIn(
name="MealPlan Shopping List", group=current_user.group, items=[ListItem(text=t) for t in all_ingredients]
name="MealPlan Shopping List", group=current_user.group, items=[ListItem(text=t.note) for t in all_ingredients]
)
created_list: ShoppingListOut = db.shopping_lists.create(session, new_list)

View File

@ -76,6 +76,9 @@ def get_recipe(recipe_slug: str, session: Session = Depends(generate_session), i
recipe: Recipe = db.recipes.get(session, recipe_slug)
if not recipe:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if recipe.settings.public or is_user:
return recipe

View File

@ -0,0 +1,8 @@
from fastapi import APIRouter
from . import food_routes, unit_routes
units_and_foods_router = APIRouter(tags=["Food and Units"])
units_and_foods_router.include_router(food_routes.router)
units_and_foods_router.include_router(unit_routes.router)

View File

@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends
from mealie.core.root_logger import get_logger
from mealie.routes.deps import get_current_user
router = APIRouter(prefix="/api/foods", dependencies=[Depends(get_current_user)])
logger = get_logger()
@router.post("")
async def create_food():
""" Create food in the Database """
# Create food
pass
@router.get("/{id}")
async def get_food():
""" Get food from the Database """
# Get food
pass
@router.put("/{id}")
async def update_food():
""" Update food in the Database """
# Update food
pass
@router.delete("/{id}")
async def delete_food():
""" Delete food from the Database """
# Delete food
pass

View File

@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends
from mealie.core.root_logger import get_logger
from mealie.routes.deps import get_current_user
router = APIRouter(prefix="/api/units", dependencies=[Depends(get_current_user)])
logger = get_logger()
@router.post("")
async def create_food():
""" Create food in the Database """
# Create food
pass
@router.get("/{id}")
async def get_food():
""" Get food from the Database """
# Get food
pass
@router.put("/{id}")
async def update_food():
""" Update food in the Database """
# Update food
pass
@router.delete("/{id}")
async def delete_food():
""" Delete food from the Database """
# Delete food
pass

View File

@ -59,6 +59,30 @@ class Nutrition(CamelModel):
orm_mode = True
class RecipeIngredientFood(CamelModel):
name: str = ""
description: str = ""
class Config:
orm_mode = True
class RecipeIngredientUnit(RecipeIngredientFood):
pass
class RecipeIngredient(CamelModel):
title: Optional[str]
note: Optional[str]
unit: Optional[RecipeIngredientUnit]
food: Optional[RecipeIngredientFood]
disable_amount: bool = True
quantity: int = 1
class Config:
orm_mode = True
class RecipeSummary(CamelModel):
id: Optional[int]
name: Optional[str]
@ -87,7 +111,7 @@ class RecipeSummary(CamelModel):
class Recipe(RecipeSummary):
recipe_yield: Optional[str]
recipe_ingredient: Optional[list[str]]
recipe_ingredient: Optional[list[RecipeIngredient]]
recipe_instructions: Optional[list[RecipeStep]]
nutrition: Optional[Nutrition]
tools: Optional[list[str]] = []
@ -134,7 +158,7 @@ class Recipe(RecipeSummary):
def getter_dict(_cls, name_orm: RecipeModel):
return {
**GetterDict(name_orm),
"recipe_ingredient": [x.ingredient for x in name_orm.recipe_ingredient],
# "recipe_ingredient": [x.note for x in name_orm.recipe_ingredient],
"recipe_category": [x.name for x in name_orm.recipe_category],
"tags": [x.name for x in name_orm.tags],
"tools": [x.tool for x in name_orm.tools],
@ -179,6 +203,16 @@ class Recipe(RecipeSummary):
return slug
@validator("recipe_ingredient", always=True, pre=True)
def validate_ingredients(recipe_ingredient, values):
if not recipe_ingredient or not isinstance(recipe_ingredient, list):
return recipe_ingredient
if all(isinstance(elem, str) for elem in recipe_ingredient):
return [RecipeIngredient(note=x) for x in recipe_ingredient]
return recipe_ingredient
class AllRecipeRequest(BaseModel):
properties: list[str]