feature/mealplanner-rewrite (#417)

* multiple recipes per day

* fix update

* meal-planner rewrite

* disable meal-tests

* spacing

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden
2021-05-22 21:04:19 -08:00
committed by GitHub
parent 4b3fc45c1c
commit ef87f2231d
42 changed files with 1502 additions and 491 deletions

View File

@@ -9,6 +9,7 @@ from mealie.routes.groups import groups_router
from mealie.routes.mealplans import meal_plan_router
from mealie.routes.media import media_router
from mealie.routes.recipe import recipe_router
from mealie.routes.shopping_list import shopping_list_router
from mealie.routes.site_settings import settings_router
from mealie.routes.users import user_router
from mealie.services.events import create_general_event
@@ -32,6 +33,7 @@ def api_routers():
# Authentication
app.include_router(user_router)
app.include_router(groups_router)
app.include_router(shopping_list_router)
# Recipes
app.include_router(recipe_router)
app.include_router(media_router)

View File

@@ -3,19 +3,21 @@ from logging import getLogger
from mealie.db.db_base import BaseDocument
from mealie.db.models.event import Event, EventNotification
from mealie.db.models.group import Group
from mealie.db.models.mealplan import MealPlanModel
from mealie.db.models.mealplan import MealPlan
from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag
from mealie.db.models.settings import CustomPage, SiteSettings
from mealie.db.models.shopping_list import ShoppingList
from mealie.db.models.sign_up import SignUp
from mealie.db.models.theme import SiteThemeModel
from mealie.db.models.users import LongLiveToken, User
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
from mealie.schema.event_notifications import EventNotificationIn
from mealie.schema.events import Event as EventSchema
from mealie.schema.meal import MealPlanInDB
from mealie.schema.meal import MealPlanOut
from mealie.schema.recipe import Recipe
from mealie.schema.settings import CustomPageOut
from mealie.schema.settings import SiteSettings as SiteSettingsSchema
from mealie.schema.shopping_list import ShoppingListOut
from mealie.schema.sign_up import SignUpOut
from mealie.schema.theme import SiteTheme
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, UserInDB
@@ -75,8 +77,8 @@ class _Tags(BaseDocument):
class _Meals(BaseDocument):
def __init__(self) -> None:
self.primary_key = "uid"
self.sql_model = MealPlanModel
self.schema = MealPlanInDB
self.sql_model = MealPlan
self.schema = MealPlanOut
class _Settings(BaseDocument):
@@ -120,7 +122,7 @@ class _Groups(BaseDocument):
self.sql_model = Group
self.schema = GroupInDB
def get_meals(self, session: Session, match_value: str, match_key: str = "name") -> list[MealPlanInDB]:
def get_meals(self, session: Session, match_value: str, match_key: str = "name") -> list[MealPlanOut]:
"""A Helper function to get the group from the database and return a sorted list of
Args:
@@ -129,13 +131,20 @@ class _Groups(BaseDocument):
match_key (str, optional): Match Key. Defaults to "name".
Returns:
list[MealPlanInDB]: [description]
list[MealPlanOut]: [description]
"""
group: GroupInDB = session.query(self.sql_model).filter_by(**{match_key: match_value}).one_or_none()
return group.mealplans
class _ShoppingList(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = ShoppingList
self.schema = ShoppingListOut
class _SignUps(BaseDocument):
def __init__(self) -> None:
self.primary_key = "token"
@@ -179,6 +188,7 @@ class Database:
self.custom_pages = _CustomPages()
self.events = _Events()
self.event_notifications = _EventNotification()
self.shopping_lists = _ShoppingList()
db = Database()

View File

@@ -3,6 +3,7 @@ from mealie.db.models.group import *
from mealie.db.models.mealplan import *
from mealie.db.models.recipe.recipe import *
from mealie.db.models.settings import *
from mealie.db.models.shopping_list import *
from mealie.db.models.sign_up import *
from mealie.db.models.theme import *
from mealie.db.models.users import *

View File

@@ -19,11 +19,18 @@ class Group(SqlAlchemyBase, BaseMixins):
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
users = orm.relationship("User", back_populates="group")
mealplans = orm.relationship(
"MealPlanModel",
"MealPlan",
back_populates="group",
single_parent=True,
order_by="MealPlanModel.startDate",
order_by="MealPlan.start_date",
)
shopping_lists = orm.relationship(
"ShoppingList",
back_populates="group",
single_parent=True,
)
categories = orm.relationship("Category", secondary=group2categories, single_parent=True)
# Webhook Settings
@@ -32,16 +39,7 @@ class Group(SqlAlchemyBase, BaseMixins):
webhook_urls = orm.relationship("WebhookURLModel", uselist=True, cascade="all, delete-orphan")
def __init__(
self,
name,
id=None,
users=None,
mealplans=None,
categories=[],
session=None,
webhook_enable=False,
webhook_time="00:00",
webhook_urls=[],
self, name, categories=[], session=None, webhook_enable=False, webhook_time="00:00", webhook_urls=[], **_
) -> None:
self.name = name
self.categories = [Category.get_ref(session=session, slug=cat.get("slug")) for cat in categories]

View File

@@ -1,50 +1,80 @@
from typing import List
import sqlalchemy as sa
import sqlalchemy.orm as orm
from mealie.db.models.group import Group
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.shopping_list import ShoppingList
from sqlalchemy import Column, Date, ForeignKey, Integer, String
from sqlalchemy.ext.orderinglist import ordering_list
class Meal(SqlAlchemyBase):
__tablename__ = "meal"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("mealplan.uid"))
slug = sa.Column(sa.String)
name = sa.Column(sa.String)
date = sa.Column(sa.Date)
image = sa.Column(sa.String)
description = sa.Column(sa.String)
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey("mealdays.id"))
position = Column(Integer)
name = Column(String)
slug = Column(String)
description = Column(String)
def __init__(self, slug, name="", description="", session=None) -> None:
if slug and slug != "":
recipe: RecipeModel = session.query(RecipeModel).filter(RecipeModel.slug == slug).one_or_none()
if recipe:
name = recipe.name
self.slug = recipe.slug
description = recipe.description
def __init__(self, slug, name, date, image, description, session=None) -> None:
self.slug = slug
self.name = name
self.date = date
self.image = image
self.description = description
class MealPlanModel(SqlAlchemyBase, BaseMixins):
class MealDay(SqlAlchemyBase, BaseMixins):
__tablename__ = "mealdays"
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey("mealplan.uid"))
date = Column(Date)
meals: list[Meal] = orm.relationship(
Meal,
cascade="all, delete, delete-orphan",
order_by="Meal.position",
collection_class=ordering_list("position"),
)
def __init__(self, date, meals: list, session=None):
self.date = date
self.meals = [Meal(**m, session=session) for m in meals]
class MealPlan(SqlAlchemyBase, BaseMixins):
__tablename__ = "mealplan"
uid = sa.Column(sa.Integer, primary_key=True, unique=True) # ! Probably Bad?
startDate = sa.Column(sa.Date)
endDate = sa.Column(sa.Date)
meals: List[Meal] = orm.relationship(Meal, cascade="all, delete, delete-orphan")
group_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id"))
uid = Column(Integer, primary_key=True, unique=True)
start_date = Column(Date)
end_date = Column(Date)
plan_days: list[MealDay] = orm.relationship(MealDay, cascade="all, delete, delete-orphan")
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="mealplans")
def __init__(self, startDate, endDate, meals, group: str, uid=None, session=None) -> None:
self.startDate = startDate
self.endDate = endDate
shopping_list_id = Column(Integer, ForeignKey("shopping_lists.id"))
shopping_list: ShoppingList = orm.relationship("ShoppingList", single_parent=True)
def __init__(
self,
start_date,
end_date,
plan_days,
group: str,
shopping_list: int = None,
session=None,
**_,
) -> None:
self.start_date = start_date
self.end_date = end_date
self.group = Group.get_ref(session, group)
self.meals = [Meal(**meal) for meal in meals]
def update(self, session, startDate, endDate, meals, uid, group) -> None:
if shopping_list:
self.shopping_list = ShoppingList.get_ref(session, shopping_list)
self.__init__(
startDate=startDate,
endDate=endDate,
meals=meals,
group=group,
session=session,
)
self.plan_days = [MealDay(**day, session=session) for day in plan_days]

View File

@@ -0,0 +1,49 @@
import sqlalchemy.orm as orm
from mealie.db.models.group import Group
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from requests import Session
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.ext.orderinglist import ordering_list
class ShoppingListItem(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_list_items"
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey("shopping_lists.id"))
position = Column(Integer, nullable=False)
title = Column(String)
text = Column(String)
quantity = Column(Integer)
checked = Column(Boolean)
def __init__(self, title, text, quantity, checked, **_) -> None:
self.title = title
self.text = text
self.quantity = quantity
self.checked = checked
class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists"
id = Column(Integer, primary_key=True)
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="shopping_lists")
name = Column(String)
items: list[ShoppingListItem] = orm.relationship(
ShoppingListItem,
cascade="all, delete, delete-orphan",
order_by="ShoppingListItem.position",
collection_class=ordering_list("position"),
)
def __init__(self, name, group, items, session=None, **_) -> None:
self.name = name
self.group = Group.get_ref(session, group)
self.items = [ShoppingListItem(**i) for i in items]
@staticmethod
def get_ref(session: Session, id: int):
return session.query(ShoppingList).filter(ShoppingList.id == id).one_or_none()

View File

@@ -2,18 +2,18 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.meal import MealPlanIn, MealPlanInDB
from mealie.schema.meal import MealPlanIn, MealPlanOut
from mealie.schema.user import GroupInDB, UserInDB
from mealie.services.events import create_group_event
from mealie.services.image import image
from mealie.services.meal_services import get_todays_meal, process_meals
from mealie.services.meal_services import get_todays_meal, set_mealplan_dates
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
@router.get("/all", response_model=list[MealPlanInDB])
@router.get("/all", response_model=list[MealPlanOut])
def get_all_meals(
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
@@ -31,11 +31,11 @@ def create_meal_plan(
current_user: UserInDB = Depends(get_current_user),
):
""" Creates a meal plan database entry """
processed_plan = process_meals(session, data)
set_mealplan_dates(data)
background_tasks.add_task(
create_group_event, "Meal Plan Created", f"Mealplan Created for '{current_user.group}'", session=session
)
return db.meals.create(session, processed_plan.dict())
return db.meals.create(session, data.dict())
@router.put("/{plan_id}")
@@ -47,8 +47,8 @@ def update_meal_plan(
current_user: UserInDB = Depends(get_current_user),
):
""" Updates a meal plan based off ID """
processed_plan = process_meals(session, meal_plan)
processed_plan = MealPlanInDB(uid=plan_id, **processed_plan.dict())
set_mealplan_dates(meal_plan)
processed_plan = MealPlanOut(uid=plan_id, **meal_plan.dict())
try:
db.meals.update(session, plan_id, processed_plan.dict())
background_tasks.add_task(
@@ -76,7 +76,7 @@ def delete_meal_plan(
raise HTTPException(status.HTTP_400_BAD_REQUEST)
@router.get("/this-week", response_model=MealPlanInDB)
@router.get("/this-week", response_model=MealPlanOut)
def get_this_week(session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)):
""" Returns the meal plan data for this week """
plans = db.groups.get_meals(session, current_user.group)

View File

@@ -1,11 +1,16 @@
from fastapi import APIRouter, Depends
from mealie.core.root_logger import get_logger
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.meal import MealPlanInDB
from mealie.schema.meal import MealPlanOut
from mealie.schema.recipe import Recipe
from mealie.schema.shopping_list import ListItem, ShoppingListIn, ShoppingListOut
from mealie.schema.user import UserInDB
from sqlalchemy.orm.session import Session
logger = get_logger()
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
@@ -13,12 +18,32 @@ router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
def get_shopping_list(
id: str,
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
current_user: UserInDB = Depends(get_current_user),
):
# ! Refactor into Single Database Call
mealplan = db.meals.get(session, id)
mealplan: MealPlanInDB
slugs = [x.slug for x in mealplan.meals]
recipes: list[Recipe] = [db.recipes.get(session, x) for x in slugs]
return [{"name": x.name, "recipe_ingredient": x.recipe_ingredient} for x in recipes if x]
mealplan: MealPlanOut = db.meals.get(session, id)
all_ingredients = []
for plan_day in mealplan.plan_days:
for meal in plan_day.meals:
if not meal.slug:
continue
try:
recipe: Recipe = db.recipes.get(session, meal.slug)
all_ingredients += recipe.recipe_ingredient
except Exception:
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]
)
created_list: ShoppingListOut = db.shopping_lists.create(session, new_list)
mealplan.shopping_list = created_list.id
db.meals.update(session, mealplan.uid, mealplan)
return created_list

View File

@@ -5,10 +5,7 @@ from mealie.routes.deps import get_current_user
from mealie.schema.category import CategoryIn, RecipeCategoryResponse
from sqlalchemy.orm.session import Session
router = APIRouter(
prefix="/api/categories",
tags=["Recipe Categories"],
)
router = APIRouter(prefix="/api/categories", tags=["Recipe Categories"])
@router.get("")

View File

@@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.shopping_list import ShoppingListIn, ShoppingListOut
from mealie.schema.user import UserInDB
from sqlalchemy.orm.session import Session
shopping_list_router = APIRouter(prefix="/api/shopping-lists", tags=["Shopping Lists"])
@shopping_list_router.post("", response_model=ShoppingListOut)
async def create_shopping_list(
list_in: ShoppingListIn,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
""" Create Shopping List in the Database """
list_in.group = current_user.group
return db.shopping_lists.create(session, list_in)
@shopping_list_router.get("/{id}", response_model=ShoppingListOut)
async def get_shopping_list(id: int, session: Session = Depends(generate_session)):
""" Get Shopping List from the Database """
return db.shopping_lists.get(session, id)
@shopping_list_router.put("/{id}", dependencies=[Depends(get_current_user)], response_model=ShoppingListOut)
async def update_shopping_list(id: int, new_data: ShoppingListIn, session: Session = Depends(generate_session)):
""" Update Shopping List in the Database """
return db.shopping_lists.update(session, id, new_data)
@shopping_list_router.delete("/{id}", dependencies=[Depends(get_current_user)])
async def delete_shopping_list(id: int, session: Session = Depends(generate_session)):
""" Delete Shopping List from the Database """
return db.shopping_lists.delete(session, id)

View File

@@ -1,51 +1,70 @@
from datetime import date
from typing import List, Optional
from typing import Optional
from mealie.db.models.mealplan import MealPlanModel
from pydantic import BaseModel, validator
from fastapi_camelcase import CamelModel
from mealie.db.models.mealplan import MealPlan
from pydantic import validator
from pydantic.utils import GetterDict
class MealIn(BaseModel):
name: Optional[str]
class MealIn(CamelModel):
slug: Optional[str]
date: Optional[date]
class MealOut(MealIn):
image: Optional[str]
name: Optional[str]
description: Optional[str]
class Config:
orm_mode = True
class MealPlanIn(BaseModel):
group: str
startDate: date
endDate: date
meals: List[MealIn]
class MealDayIn(CamelModel):
date: Optional[date]
meals: list[MealIn]
@validator("endDate")
def endDate_after_startDate(v, values, config, field):
if "startDate" in values and v < values["startDate"]:
class Config:
orm_mode = True
class MealDayOut(MealDayIn):
id: int
class Config:
orm_mode = True
class MealPlanIn(CamelModel):
group: str
start_date: date
end_date: date
plan_days: list[MealDayIn]
@validator("end_date")
def end_date_after_start_date(v, values, config, field):
if "start_date" in values and v < values["start_date"]:
raise ValueError("EndDate should be greater than StartDate")
return v
class MealPlanProcessed(MealPlanIn):
meals: list[MealOut]
class Config:
orm_mode = True
class MealPlanInDB(MealPlanProcessed):
uid: str
class MealPlanOut(MealPlanIn):
uid: int
shopping_list: Optional[int]
class Config:
orm_mode = True
@classmethod
def getter_dict(_cls, name_orm: MealPlanModel):
return {
**GetterDict(name_orm),
"group": name_orm.group.name,
}
def getter_dict(_cls, name_orm: MealPlan):
try:
return {
**GetterDict(name_orm),
"group": name_orm.group.name,
"shopping_list": name_orm.shopping_list.id,
}
except Exception:
return {
**GetterDict(name_orm),
"group": name_orm.group.name,
"shopping_list": None,
}

View File

@@ -0,0 +1,35 @@
from typing import Optional
from fastapi_camelcase import CamelModel
from mealie.db.models.shopping_list import ShoppingList
from pydantic.utils import GetterDict
class ListItem(CamelModel):
title: Optional[str]
text: str = ""
quantity: int = 1
checked: bool = False
class Config:
orm_mode = True
class ShoppingListIn(CamelModel):
name: str
group: Optional[str]
items: list[ListItem]
class ShoppingListOut(ShoppingListIn):
id: int
class Config:
orm_mode = True
@classmethod
def getter_dict(cls, ormModel: ShoppingList):
return {
**GetterDict(ormModel),
"group": ormModel.group.name,
}

View File

@@ -5,7 +5,8 @@ from mealie.core.config import settings
from mealie.db.models.group import Group
from mealie.db.models.users import User
from mealie.schema.category import CategoryBase
from mealie.schema.meal import MealPlanInDB
from mealie.schema.meal import MealPlanOut
from mealie.schema.shopping_list import ShoppingListOut
from pydantic.types import constr
from pydantic.utils import GetterDict
@@ -105,7 +106,8 @@ class UpdateGroup(GroupBase):
class GroupInDB(UpdateGroup):
users: Optional[list[UserOut]]
mealplans: Optional[list[MealPlanInDB]]
mealplans: Optional[list[MealPlanOut]]
shopping_lists: Optional[list[ShoppingListOut]]
class Config:
orm_mode = True

View File

@@ -3,41 +3,16 @@ from typing import Union
from mealie.db.database import db
from mealie.db.db_setup import create_session
from mealie.schema.meal import MealIn, MealOut, MealPlanIn, MealPlanInDB, MealPlanProcessed
from mealie.schema.meal import MealDayIn, MealPlanIn
from mealie.schema.recipe import Recipe
from mealie.schema.user import GroupInDB
from sqlalchemy.orm.session import Session
def process_meals(session: Session, meal_plan_base: MealPlanIn) -> MealPlanProcessed:
meals = []
for x, meal in enumerate(meal_plan_base.meals):
meal: MealIn
try:
recipe: Recipe = db.recipes.get(session, meal.slug)
meal_data = MealOut(
slug=recipe.slug,
name=recipe.name,
date=meal_plan_base.startDate + timedelta(days=x),
image=recipe.image,
description=recipe.description,
)
except Exception:
meal_data = MealOut(
date=meal_plan_base.startDate + timedelta(days=x),
)
meals.append(meal_data)
return MealPlanProcessed(
group=meal_plan_base.group,
meals=meals,
startDate=meal_plan_base.startDate,
endDate=meal_plan_base.endDate,
)
def set_mealplan_dates(meal_plan_base: MealPlanIn) -> MealPlanIn:
for x, plan_days in enumerate(meal_plan_base.plan_days):
plan_days: MealDayIn
plan_days.date = meal_plan_base.start_date + timedelta(days=x)
def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe:
@@ -52,6 +27,7 @@ def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe:
Returns:
Recipe: Pydantic Recipe Object
"""
session = session or create_session()
if isinstance(group, int):
@@ -60,12 +36,12 @@ def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe:
today_slug = None
for mealplan in group.mealplans:
mealplan: MealPlanInDB
for meal in mealplan.meals:
meal: MealOut
if meal.date == date.today():
today_slug = meal.slug
break
for plan_day in mealplan.plan_days:
if plan_day.date == date.today():
if plan_day.meals[0].slug and plan_day.meals[0].slug != "":
today_slug = plan_day.meals[0].slug
else:
return plan_day.meals[0]
if today_slug:
return db.recipes.get(session, today_slug)