Feature/restore-recipe-functionality (#810)

* feat(frontend):  add back support for assets

* feat(backend):  add back support for assets

* feat(frontend):  add support for recipe tools

* feat(backend):  add support for recipe tools

* feat(frontend):  add onHand support for recipe toosl

* feat(backend):  add onHand support for backend

* refactor(frontend): ♻️ move items to recipe folder and break apart types

* feat(frontend):  add support for recipe comments

* feat(backend):  Add support for recipe comments

* fix(backend): 💥 disable comments import

* fix(frontend): 🐛 fix rendering issue with titles when moving steps

* add tools to changelog

* fix type errors

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden
2021-11-22 20:10:48 -09:00
committed by GitHub
parent 912cc6d956
commit 7afdd5b577
43 changed files with 1221 additions and 423 deletions

View File

@@ -13,6 +13,7 @@ from mealie.db.models.recipe.comment import RecipeComment
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool
from mealie.db.models.server.task import ServerTaskModel
from mealie.db.models.sign_up import SignUp
from mealie.db.models.users import LongLiveToken, User
@@ -24,8 +25,9 @@ from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.invite_token import ReadInviteToken
from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
from mealie.schema.recipe import CommentOut, Recipe, RecipeCategoryResponse, RecipeTagResponse
from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
from mealie.schema.recipe.recipe_tool import RecipeTool
from mealie.schema.server import ServerTask
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser, SignUpOut
from mealie.schema.user.user_passwords import PrivatePasswordResetToken
@@ -78,8 +80,12 @@ class Database:
return AccessModel(self.session, pk_id, IngredientUnitModel, IngredientUnit)
@cached_property
def comments(self) -> AccessModel[CommentOut, RecipeComment]:
return AccessModel(self.session, pk_id, RecipeComment, CommentOut)
def tools(self) -> AccessModel[RecipeTool, Tool]:
return AccessModel(self.session, pk_id, Tool, RecipeTool)
@cached_property
def comments(self) -> AccessModel[RecipeCommentOut, RecipeComment]:
return AccessModel(self.session, pk_id, RecipeComment, RecipeCommentOut)
@cached_property
def categories(self) -> CategoryDataAccessModel:

View File

@@ -11,6 +11,7 @@ class GUID(TypeDecorator):
"""
impl = CHAR
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
@@ -31,9 +32,6 @@ class GUID(TypeDecorator):
return "%.32x" % value.int
def process_result_value(self, value, dialect):
if value is None:
return value
else:
if not isinstance(value, uuid.UUID):
value = uuid.UUID(value)
return value
if value is not None and not isinstance(value, uuid.UUID):
value = uuid.UUID(value)
return value

View File

@@ -7,7 +7,7 @@ class ApiExtras(SqlAlchemyBase):
__tablename__ = "api_extras"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
key_name = sa.Column(sa.String, unique=True)
key_name = sa.Column(sa.String)
value = sa.Column(sa.String)
def __init__(self, key, value) -> None:

View File

@@ -1,37 +1,28 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, orm
from sqlalchemy import Column, ForeignKey, Integer, String, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.users import User
def generate_uuid():
return str(uuid4())
from mealie.db.models._model_utils import auto_init
from mealie.db.models._model_utils.guid import GUID
class RecipeComment(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_comments"
id = Column(Integer, primary_key=True)
uuid = Column(String, unique=True, nullable=False, default=generate_uuid)
parent_id = Column(Integer, ForeignKey("recipes.id"), nullable=False)
recipe = orm.relationship("RecipeModel", back_populates="comments")
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user = orm.relationship("User", back_populates="comments", single_parent=True, foreign_keys=[user_id])
date_added = Column(DateTime, default=datetime.now)
id = Column(GUID(), primary_key=True, default=uuid4)
text = Column(String)
def __init__(self, recipe_slug, user, text, session, date_added=None, **_) -> None:
self.text = text
self.recipe = RecipeModel.get_ref(session, recipe_slug, "slug")
self.date_added = date_added or datetime.now()
# Recipe Link
recipe_id = Column(Integer, ForeignKey("recipes.id"), nullable=False)
recipe = orm.relationship("RecipeModel", back_populates="comments")
if isinstance(user, dict):
user = user.get("id")
# User Link
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user = orm.relationship("User", back_populates="comments", single_parent=True, foreign_keys=[user_id])
self.user = User.get_ref(session, user)
@auto_init()
def __init__(self, **_) -> None:
pass
def update(self, text, **_) -> None:
self.text = text

View File

@@ -1,3 +1,5 @@
from uuid import uuid4
from sqlalchemy import Column, ForeignKey, Integer, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase
@@ -17,7 +19,7 @@ class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
class RecipeInstruction(SqlAlchemyBase):
__tablename__ = "recipe_instructions"
id = Column(Integer, primary_key=True)
id = Column(GUID(), primary_key=True, default=uuid4)
parent_id = Column(Integer, ForeignKey("recipes.id"))
position = Column(Integer)
type = Column(String, default="")

View File

@@ -18,7 +18,22 @@ from .note import Note
from .nutrition import Nutrition
from .settings import RecipeSettings
from .tag import Tag, recipes2tags
from .tool import Tool
from .tool import recipes_to_tools
# Decorator function to unpack the extras into a dict
def recipe_extras(func):
def wrapper(*args, **kwargs):
extras = kwargs.pop("extras")
if extras is None:
extras = []
extras = [{"key": key, "value": value} for key, value in extras.items()]
return func(*args, extras=extras, **kwargs)
return wrapper
class RecipeModel(SqlAlchemyBase, BaseMixins):
@@ -52,10 +67,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
recipe_yield = sa.Column(sa.String)
recipeCuisine = sa.Column(sa.String)
tools: list[Tool] = orm.relationship("Tool", cascade="all, delete-orphan")
assets: list[RecipeAsset] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
assets = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
recipe_category: list = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes")
tools = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes")
recipe_ingredient: list[RecipeIngredient] = orm.relationship(
"RecipeIngredient",
@@ -88,45 +103,36 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
get_attr = "slug"
exclude = {
"assets",
"extras",
"notes",
"nutrition",
"recipe_ingredient",
"settings",
"tools",
}
@validates("name")
def validate_name(self, key, name):
def validate_name(self, _, name):
assert name != ""
return name
@recipe_extras
@auto_init()
def __init__(
self,
session,
assets: list = None,
extras: dict = None,
notes: list[dict] = None,
nutrition: dict = None,
recipe_ingredient: list[str] = None,
settings: dict = None,
tools: list[str] = None,
**_,
) -> None:
self.nutrition = Nutrition(**nutrition) if nutrition else Nutrition()
self.tools = [Tool(tool=x) for x in tools] if tools else []
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))
# for instruc in recipe_instructions
# ]
# Mealie Specific
self.settings = RecipeSettings(**settings) if settings else RecipeSettings()
self.notes = [Note(**note) for note in notes]
self.extras = [ApiExtras(key=key, value=value) for key, value in extras.items()]
# Time Stampes
self.date_updated = datetime.datetime.now()

View File

@@ -1,13 +1,23 @@
import sqlalchemy as sa
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Table, orm
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import auto_init
recipes_to_tools = Table(
"recipes_to_tools",
SqlAlchemyBase.metadata,
Column("recipe_id", Integer, ForeignKey("recipes.id")),
Column("tool_id", Integer, ForeignKey("tools.id")),
)
class Tool(SqlAlchemyBase):
class Tool(SqlAlchemyBase, BaseMixins):
__tablename__ = "tools"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
tool = sa.Column(sa.String)
name = Column(String, index=True, unique=True, nullable=False)
on_hand = Column(Boolean, default=False)
recipes = orm.relationship("RecipeModel", secondary=recipes_to_tools, back_populates="tools")
def __init__(self, tool) -> None:
self.tool = tool
@auto_init()
def __init__(self, name, on_hand, **_) -> None:
self.on_hand = on_hand
self.name = name

View File

@@ -1,6 +1,20 @@
from fastapi import APIRouter
from . import admin, app, auth, categories, groups, parser, recipe, shopping_lists, tags, unit_and_foods, users
from . import (
admin,
app,
auth,
categories,
comments,
groups,
parser,
recipe,
shopping_lists,
tags,
tools,
unit_and_foods,
users,
)
router = APIRouter(prefix="/api")
@@ -9,8 +23,10 @@ router.include_router(auth.router)
router.include_router(users.router)
router.include_router(groups.router)
router.include_router(recipe.router)
router.include_router(comments.router)
router.include_router(parser.router)
router.include_router(unit_and_foods.router)
router.include_router(tools.router)
router.include_router(categories.router)
router.include_router(tags.router)
router.include_router(shopping_lists.router)

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.recipe.recipe_comments_service import RecipeCommentsService
router = APIRouter()
router.include_router(RouterFactory(RecipeCommentsService, prefix="/comments", tags=["Recipe: Comments"]))

View File

@@ -1,58 +1,20 @@
from http.client import HTTPException
from fastapi import Depends, status
from fastapi import Depends
from sqlalchemy.orm.session import Session
from mealie.core.dependencies import get_current_user
from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe import CommentOut, CreateComment, SaveComment
from mealie.schema.user import PrivateUser
from mealie.schema.recipe.recipe_comments import RecipeCommentOut
router = UserAPIRouter()
@router.post("/{slug}/comments")
async def create_comment(
@router.get("/{slug}/comments", response_model=list[RecipeCommentOut])
async def get_recipe_comments(
slug: str,
new_comment: CreateComment,
session: Session = Depends(generate_session),
current_user: PrivateUser = Depends(get_current_user),
):
"""Create comment in the Database"""
"""Get all comments for a recipe"""
db = get_database(session)
new_comment = SaveComment(user=current_user.id, text=new_comment.text, recipe_slug=slug)
return db.comments.create(new_comment)
@router.put("/{slug}/comments/{id}")
async def update_comment(
id: int,
new_comment: CreateComment,
session: Session = Depends(generate_session),
current_user: PrivateUser = Depends(get_current_user),
):
"""Update comment in the Database"""
db = get_database(session)
old_comment: CommentOut = db.comments.get(id)
if current_user.id != old_comment.user.id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
return db.comments.update(id, new_comment)
@router.delete("/{slug}/comments/{id}")
async def delete_comment(
id: int, session: Session = Depends(generate_session), current_user: PrivateUser = Depends(get_current_user)
):
"""Delete comment from the Database"""
db = get_database(session)
comment: CommentOut = db.comments.get(id)
if current_user.id == comment.user.id or current_user.admin:
db.comments.delete(id)
return
raise HTTPException(status.HTTP_403_FORBIDDEN)
recipe = db.recipes.get_one(slug)
return db.comments.multi_query({"recipe_id": recipe.id})

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.recipe.recipe_tool_service import RecipeToolService
router = APIRouter()
router.include_router(RouterFactory(RecipeToolService, prefix="/tools", tags=["Recipes: Tools"]))

View File

@@ -11,12 +11,13 @@ from mealie.core.config import get_app_dirs
from mealie.db.models.recipe.recipe import RecipeModel
from .recipe_asset import RecipeAsset
from .recipe_comments import CommentOut
from .recipe_comments import RecipeCommentOut
from .recipe_ingredient import RecipeIngredient
from .recipe_notes import RecipeNote
from .recipe_nutrition import Nutrition
from .recipe_settings import RecipeSettings
from .recipe_step import RecipeStep
from .recipe_tool import RecipeTool
app_dirs = get_app_dirs()
@@ -101,7 +102,7 @@ class Recipe(RecipeSummary):
recipe_ingredient: Optional[list[RecipeIngredient]] = []
recipe_instructions: Optional[list[RecipeStep]] = []
nutrition: Optional[Nutrition]
tools: Optional[list[str]] = []
tools: list[RecipeTool] = []
# Mealie Specific
settings: Optional[RecipeSettings] = RecipeSettings()
@@ -109,7 +110,7 @@ class Recipe(RecipeSummary):
notes: Optional[list[RecipeNote]] = []
extras: Optional[dict] = {}
comments: Optional[list[CommentOut]] = []
comments: Optional[list[RecipeCommentOut]] = []
@staticmethod
def directory_from_slug(slug) -> Path:
@@ -143,7 +144,6 @@ class Recipe(RecipeSummary):
# "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],
"extras": {x.key_name: x.value for x in name_orm.extras},
}

View File

@@ -1,8 +1,8 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from fastapi_camelcase import CamelModel
from pydantic.utils import GetterDict
class UserBase(CamelModel):
@@ -14,31 +14,27 @@ class UserBase(CamelModel):
orm_mode = True
class CreateComment(CamelModel):
class RecipeCommentCreate(CamelModel):
recipe_id: int
text: str
class SaveComment(CreateComment):
recipe_slug: str
user: int
class Config:
orm_mode = True
class RecipeCommentSave(RecipeCommentCreate):
user_id: int
class CommentOut(CreateComment):
id: int
uuid: str
recipe_slug: str
date_added: datetime
class RecipeCommentUpdate(CamelModel):
id: UUID
text: str
class RecipeCommentOut(RecipeCommentCreate):
id: UUID
recipe_id: int
created_at: datetime
update_at: datetime
user_id: int
user: UserBase
class Config:
orm_mode = True
@classmethod
def getter_dict(_cls, name_orm):
return {
**GetterDict(name_orm),
"recipe_slug": name_orm.recipe.slug,
}

View File

@@ -1,7 +1,8 @@
from typing import Optional
from uuid import UUID
from uuid import UUID, uuid4
from fastapi_camelcase import CamelModel
from pydantic import Field
class IngredientReferences(CamelModel):
@@ -16,6 +17,7 @@ class IngredientReferences(CamelModel):
class RecipeStep(CamelModel):
id: Optional[UUID] = Field(default_factory=uuid4)
title: Optional[str] = ""
text: str
ingredient_references: list[IngredientReferences] = []

View File

@@ -0,0 +1,13 @@
from fastapi_camelcase import CamelModel
class RecipeToolCreate(CamelModel):
name: str
on_hand: bool = False
class RecipeTool(RecipeToolCreate):
id: int
class Config:
orm_mode = True

View File

@@ -8,15 +8,15 @@ from pydantic.main import BaseModel
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.db.database import get_database
from mealie.schema.admin import CommentImport, GroupImport, NotificationImport, RecipeImport, UserImport
from mealie.schema.events import EventNotificationIn
from mealie.schema.recipe import CommentOut, Recipe
from mealie.schema.recipe import Recipe, RecipeCommentOut
from mealie.schema.user import PrivateUser, UpdateGroup
from mealie.services.image import minify
app_dirs = get_app_dirs()
class ImportDatabase:
def __init__(
@@ -57,7 +57,10 @@ class ImportDatabase:
successful_imports = {}
recipes = ImportDatabase.read_models_file(
file_path=recipe_dir, model=Recipe, single_file=False, migrate=ImportDatabase._recipe_migration
file_path=recipe_dir,
model=Recipe,
single_file=False,
migrate=ImportDatabase._recipe_migration,
)
for recipe in recipes:
@@ -76,7 +79,7 @@ class ImportDatabase:
)
if import_status.status:
successful_imports.update({recipe.slug: recipe})
successful_imports[recipe.slug] = recipe
imports.append(import_status)
@@ -90,10 +93,10 @@ class ImportDatabase:
if not comment_dir.exists():
return
comments = ImportDatabase.read_models_file(file_path=comment_dir, model=CommentOut)
comments = ImportDatabase.read_models_file(file_path=comment_dir, model=RecipeCommentOut)
for comment in comments:
comment: CommentOut
comment: RecipeCommentOut
self.import_model(
db_table=self.db.comments,
@@ -130,6 +133,8 @@ class ImportDatabase:
if type(recipe_dict["extras"]) == list:
recipe_dict["extras"] = {}
recipe_dict["comments"] = []
return recipe_dict
def _import_images(self, successful_imports: list[Recipe]):
@@ -328,8 +333,8 @@ def import_database(
if import_notifications:
notification_report = import_session.import_notifications()
if import_recipes:
import_session.import_comments()
# if import_recipes:
# import_session.import_comments()
import_session.clean_up()

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from functools import cached_property
from uuid import UUID
from fastapi import HTTPException
from mealie.schema.recipe.recipe_comments import (
RecipeCommentCreate,
RecipeCommentOut,
RecipeCommentSave,
RecipeCommentUpdate,
)
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeCommentsService(
CrudHttpMixins[RecipeCommentOut, RecipeCommentCreate, RecipeCommentCreate],
UserHttpService[UUID, RecipeCommentOut],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = RecipeCommentOut
@cached_property
def dal(self):
return self.db.comments
def _check_comment_belongs_to_user(self) -> None:
if self.item.user_id != self.user.id and not self.user.admin:
raise HTTPException(detail="Comment does not belong to user")
def populate_item(self, id: UUID) -> RecipeCommentOut:
self.item = self.dal.get_one(id)
return self.item
def get_all(self) -> list[RecipeCommentOut]:
return self.dal.get_all()
def create_one(self, data: RecipeCommentCreate) -> RecipeCommentOut:
save_data = RecipeCommentSave(text=data.text, user_id=self.user.id, recipe_id=data.recipe_id)
return self._create_one(save_data)
def update_one(self, data: RecipeCommentUpdate, item_id: UUID = None) -> RecipeCommentOut:
self._check_comment_belongs_to_user()
return self._update_one(data, item_id)
def delete_one(self, item_id: UUID = None) -> RecipeCommentOut:
self._check_comment_belongs_to_user()
return self._delete_one(item_id)

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from functools import cached_property
from mealie.schema.recipe.recipe_tool import RecipeTool, RecipeToolCreate
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeToolService(
CrudHttpMixins[RecipeTool, RecipeToolCreate, RecipeToolCreate],
UserHttpService[int, RecipeTool],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = RecipeTool
@cached_property
def dal(self):
return self.db.tools
def populate_item(self, id: int) -> RecipeTool:
self.item = self.dal.get_one(id)
return self.item
def get_all(self) -> list[RecipeTool]:
return self.dal.get_all()
def create_one(self, data: RecipeToolCreate) -> RecipeTool:
return self._create_one(data)
def update_one(self, data: RecipeTool, item_id: int = None) -> RecipeTool:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> RecipeTool:
return self._delete_one(id)