feature/recipe-comments (#448)

* fix favorite color issue

* db and models for comments

* rename files

* initial UI for comments

* fix format

* import / export

* fixes #428

* format

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-05-29 20:50:17 -08:00 committed by GitHub
parent 6f38fcf81b
commit 2b97af5728
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 360 additions and 19 deletions

View File

@ -1,4 +1,3 @@
import json
import re
from enum import Enum
from itertools import groupby
@ -81,8 +80,6 @@ class PathObject(BaseModel):
def get_path_objects(app: FastAPI):
paths = []
with open("scratch.json", "w") as f:
f.write(json.dumps(app.openapi()))
for key, value in app.openapi().items():
if key == "paths":
for key, value in value.items():

View File

@ -107,6 +107,12 @@ class AppRoutes:
def recipes_recipe_slug_image(self, recipe_slug):
return f"{self.prefix}/recipes/{recipe_slug}/image"
def recipes_slug_comments(self, slug):
return f"{self.prefix}/recipes/{slug}/comments"
def recipes_slug_comments_id(self, slug, id):
return f"{self.prefix}/recipes/{slug}/comments/{id}"
def shopping_lists_id(self, id):
return f"{self.prefix}/shopping-lists/{id}"
@ -126,7 +132,7 @@ class AppRoutes:
return f"{self.prefix}/users/{id}"
def users_id_favorites(self, id):
return f"{self.prefix}/users/{id}/favorites/"
return f"{self.prefix}/users/{id}/favorites"
def users_id_favorites_slug(self, id, slug):
return f"{self.prefix}/users/{id}/favorites/{slug}"

View File

@ -65,6 +65,8 @@ export const API_ROUTES = {
recipesRecipeSlug: (recipe_slug) => `${prefix}/recipes/${recipe_slug}`,
recipesRecipeSlugAssets: (recipe_slug) => `${prefix}/recipes/${recipe_slug}/assets`,
recipesRecipeSlugImage: (recipe_slug) => `${prefix}/recipes/${recipe_slug}/image`,
recipesSlugComments: (slug) => `${prefix}/recipes/${slug}/comments`,
recipesSlugCommentsId: (slug, id) => `${prefix}/recipes/${slug}/comments/${id}`,
shoppingListsId: (id) => `${prefix}/shopping-lists/${id}`,
siteSettingsCustomPagesId: (id) => `${prefix}/site-settings/custom-pages/${id}`,
tagsTag: (tag) => `${prefix}/tags/${tag}`,

View File

@ -1,5 +1,6 @@
import { baseURL } from "./api-utils";
import { API_ROUTES } from "./apiRoutes";
import { apiReq } from "./api-utils";
import { baseURL } from "./api-utils";
import { store } from "../store";
import i18n from "@/i18n.js";
@ -161,4 +162,28 @@ export const recipeAPI = {
recipeAssetPath(recipeSlug, assetName) {
return `api/media/recipes/${recipeSlug}/assets/${assetName}`;
},
/** Create comment in the Database
* @param slug
*/
async createComment(slug, data) {
const response = await apiReq.post(API_ROUTES.recipesSlugComments(slug), data);
return response.data;
},
/** Update comment in the Database
* @param slug
* @param id
*/
async updateComment(slug, id, data) {
const response = await apiReq.put(API_ROUTES.recipesSlugCommentsId(slug, id), data);
return response.data;
},
/** Delete comment from the Database
* @param slug
* @param id
*/
async deleteComment(slug, id) {
const response = await apiReq.delete(API_ROUTES.recipesSlugCommentsId(slug, id));
return response.data;
},
};

View File

@ -29,7 +29,7 @@
</CardImage>
<v-card-title class="my-n3 mb-n6">
{{ $d(new Date(planDay.date.split("-")), "short") }}
{{ $d(new Date(planDay.date.replaceAll("-", "/")), "short") }}
</v-card-title>
<v-card-subtitle class="mb-0 pb-0"> {{ planDay.meals[0].name }}</v-card-subtitle>
<v-hover v-slot="{ hover }">

View File

@ -140,6 +140,7 @@ export default {
dateDif() {
let startDate = new Date(this.startDate);
let endDate = new Date(this.endDate);
console.log(startDate, endDate);
let dateDif = (endDate - startDate) / (1000 * 3600 * 24) + 1;
@ -227,6 +228,7 @@ export default {
nextEndDate.setDate(nextEndDate.getDate() + 4);
this.startDate = nextMonday.toISOString().substr(0, 10);
this.endDate = nextEndDate.toISOString().substr(0, 10);
},
},

View File

@ -0,0 +1,121 @@
<template>
<v-card>
<v-card-title class="headline">
<v-icon large class="mr-2">
mdi-comment-text-multiple-outline
</v-icon>
Comments
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card class="ma-2" v-for="(comment, index) in comments" :key="comment.id">
<v-list-item two-line>
<v-list-item-avatar color="accent" class="white--text">
<img :src="getProfileImage(comment.user.id)" />
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title> {{ comment.user.username }}</v-list-item-title>
<v-list-item-subtitle> {{ $d(new Date(comment.dateAdded), "short") }} </v-list-item-subtitle>
</v-list-item-content>
<v-card-actions v-if="loggedIn">
<TheButton
small
minor
v-if="!editKeys[comment.id] && (user.admin || comment.user.id === user.id)"
delete
@click="deleteComment(comment.id)"
/>
<TheButton
small
v-if="!editKeys[comment.id] && comment.user.id === user.id"
edit
@click="editComment(comment.id)"
/>
<TheButton small v-else-if="editKeys[comment.id]" update @click="updateComment(comment.id, index)" />
</v-card-actions>
</v-list-item>
<div>
<v-card-text>
{{ !editKeys[comment.id] ? comment.text : null }}
<v-textarea v-if="editKeys[comment.id]" v-model="comment.text"> </v-textarea>
</v-card-text>
</div>
</v-card>
<v-card-text v-if="loggedIn">
<v-textarea auto-grow row-height="1" outlined v-model="newComment"> </v-textarea>
<div class="d-flex">
<TheButton class="ml-auto" create @click="createNewComment"> Comment </TheButton>
</div>
</v-card-text>
</v-card>
</template>
<script>
import { api } from "@/api";
const NEW_COMMENT_EVENT = "new-comment";
const UPDATE_COMMENT_EVENT = "update-comment";
export default {
props: {
comments: {
type: Array,
},
slug: {
type: String,
},
},
data() {
return {
newComment: "",
editKeys: {},
};
},
computed: {
user() {
return this.$store.getters.getUserData;
},
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
},
watch: {
comments() {
for (const comment of this.comments) {
this.$set(this.editKeys, comment.id, false);
}
},
editKeys() {
console.log(this.editKeys);
},
},
methods: {
resetImage() {
this.hideImage == false;
},
getProfileImage(id) {
return `api/users/${id}/image`;
},
editComment(id) {
this.$set(this.editKeys, id, true);
},
async updateComment(id, index) {
this.$set(this.editKeys, id, false);
await api.recipes.updateComment(this.slug, id, this.comments[index]);
this.$emit(UPDATE_COMMENT_EVENT);
},
async createNewComment() {
console.log(this.slug);
await api.recipes.createComment(this.slug, { text: this.newComment });
this.$emit(NEW_COMMENT_EVENT);
this.newComment = "";
},
async deleteComment(id) {
await api.recipes.deleteComment(this.slug, id);
this.$emit(UPDATE_COMMENT_EVENT);
},
},
};
</script>
<style lang="scss" scoped>
</style>

View File

@ -3,7 +3,7 @@
small
@click.prevent="toggleFavorite"
v-if="isFavorite || showAlways"
:color="isFavorite && buttonStyle ? 'secondary' : 'primary'"
:color="buttonStyle ? 'primary' : 'secondary'"
:icon="!buttonStyle"
:fab="buttonStyle"
>

View File

@ -13,8 +13,8 @@
<v-col :sm="6" :md="6" :lg="4" :xl="3" v-for="(mealplan, i) in plannedMeals" :key="i">
<v-card class="mt-1">
<v-card-title class="mb-0 pb-0">
{{ $d(new Date(mealplan.startDate.split("-")), "short") }} -
{{ $d(new Date(mealplan.endDate.split("-")), "short") }}
{{ $d(new Date(mealplan.startDate.replaceAll("-", "/")), "short") }} -
{{ $d(new Date(mealplan.endDate.replaceAll("-", "/")), "short") }}
</v-card-title>
<v-divider class="mx-2 pa-1"></v-divider>
<v-card-actions class="mb-0 px-2 py-0">
@ -22,7 +22,7 @@
<v-icon left small>
mdi-cart-check
</v-icon>
{{$t('shopping-list.create-shopping-list')}}
{{ $t("shopping-list.create-shopping-list") }}
</v-btn>
<v-btn
text
@ -35,10 +35,12 @@
<v-icon left small>
mdi-cart-check
</v-icon>
{{$t('shopping-list.shopping-list')}}
{{ $t("shopping-list.shopping-list") }}
</v-btn>
<v-spacer></v-spacer>
<TheCopyButton color="info" :copy-text="mealPlanURL(mealplan.uid)"> {{$t('general.link-copied')}} </TheCopyButton>
<TheCopyButton color="info" :copy-text="mealPlanURL(mealplan.uid)">
{{ $t("general.link-copied") }}
</TheCopyButton>
</v-card-actions>
<v-list class="mt-0 pt-0">
@ -48,7 +50,9 @@
<v-img :src="getImage(planDay['meals'][0].slug)"></v-img>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-html="$d(new Date(planDay.date.split('-')), 'short')"></v-list-item-title>
<v-list-item-title
v-html="$d(new Date(planDay.date.replaceAll('-', '/')), 'short')"
></v-list-item-title>
<v-list-item-subtitle v-html="planDay['meals'][0].name"></v-list-item-subtitle>
</v-list-item-content>
</template>

View File

@ -44,6 +44,13 @@
/>
<RecipeEditor v-else v-model="recipeDetails" ref="recipeEditor" @upload="getImageFile" />
</v-card>
<CommentsSection
class="mt-2"
:slug="recipeDetails.slug"
:comments="recipeDetails.comments"
@new-comment="getRecipeDetails"
@update-comment="getRecipeDetails"
/>
<PrintView :recipe="recipeDetails" />
</v-container>
</template>
@ -60,6 +67,7 @@ import EditorButtonRow from "@/components/Recipe/EditorButtonRow";
import NoRecipe from "@/components/Fallbacks/NoRecipe";
import { user } from "@/mixins/user";
import { router } from "@/routes";
import CommentsSection from "@/components/Recipe/CommentSection";
export default {
components: {
@ -71,6 +79,7 @@ export default {
PrintView,
NoRecipe,
FavoriteBadge,
CommentsSection,
},
mixins: [user],
inject: {

View File

@ -5,6 +5,7 @@ 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 MealPlan
from mealie.db.models.recipe.comment import RecipeComment
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
@ -12,6 +13,7 @@ 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.comments import CommentOut
from mealie.schema.event_notifications import EventNotificationIn
from mealie.schema.events import Event as EventSchema
from mealie.schema.meal import MealPlanOut
@ -110,6 +112,13 @@ class _Users(BaseDocument):
return self.schema.from_orm(entry)
class _Comments(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = RecipeComment
self.schema = CommentOut
class _LongLiveToken(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
@ -190,6 +199,7 @@ class Database:
self.events = _Events()
self.event_notifications = _EventNotification()
self.shopping_lists = _ShoppingList()
self.comments = _Comments()
db = Database()

View File

@ -8,6 +8,7 @@ class BaseMixins:
def update(self, *args, **kwarg):
self.__init__(*args, **kwarg)
@classmethod
def get_ref(cls_type, session: Session, match_value: str, match_attr: str = "id"):
eff_ref = getattr(cls_type, match_attr)
return session.query(cls_type).filter(eff_ref == match_value).one_or_none()

View File

@ -16,7 +16,6 @@ class RecipeAsset(SqlAlchemyBase):
icon=None,
file_name=None,
) -> None:
print("Asset Saved", name)
self.name = name
self.file_name = file_name
self.icon = icon

View File

@ -0,0 +1,36 @@
from datetime import datetime
from uuid import uuid4
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.users import User
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, orm
def generate_uuid():
return str(uuid4())
class RecipeComment(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_comments"
id = Column(Integer, primary_key=True)
uuid = Column(Integer, 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)
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()
if isinstance(user, dict):
user = user.get("id")
self.user = User.get_ref(session, user)
def update(self, text, **_) -> None:
self.text = text

View File

@ -55,6 +55,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
collection_class=ordering_list("position"),
)
comments: list = orm.relationship("RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan")
# Mealie Specific
slug = sa.Column(sa.String, index=True, unique=True)
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan")

View File

@ -33,6 +33,10 @@ class User(SqlAlchemyBase, BaseMixins):
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
)
comments: list = orm.relationship(
"RecipeComment", back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
)
favorite_recipes: list[RecipeModel] = orm.relationship(RecipeModel, back_populates="favorited_by")
def __init__(
@ -56,8 +60,7 @@ class User(SqlAlchemyBase, BaseMixins):
self.password = password
self.favorite_recipes = [
RecipeModel.get_ref(RecipeModel, session=session, match_value=x, match_attr="slug")
for x in favorite_recipes
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
]
if self.username is None:
@ -78,8 +81,7 @@ class User(SqlAlchemyBase, BaseMixins):
self.password = password
self.favorite_recipes = [
RecipeModel.get_ref(RecipeModel, session=session, match_value=x, match_attr="slug")
for x in favorite_recipes
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
]
def update_password(self, password):

View File

@ -1,5 +1,5 @@
from fastapi import APIRouter
from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_crud_routes, tag_routes
from mealie.routes.recipe import all_recipe_routes, category_routes, comments, recipe_crud_routes, tag_routes
recipe_router = APIRouter()
@ -7,3 +7,4 @@ recipe_router.include_router(all_recipe_routes.router)
recipe_router.include_router(recipe_crud_routes.router)
recipe_router.include_router(category_routes.router)
recipe_router.include_router(tag_routes.router)
recipe_router.include_router(comments.router)

View File

@ -0,0 +1,54 @@
from http.client import HTTPException
from fastapi import APIRouter, Depends, 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.comments import CommentIn, CommentOut, CommentSaveToDB
from mealie.schema.user import UserInDB
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api", tags=["Recipe Comments"])
@router.post("/recipes/{slug}/comments")
async def create_comment(
slug: str,
new_comment: CommentIn,
session: Session = Depends(generate_session),
current_user: UserInDB = Depends(get_current_user),
):
""" Create comment in the Database """
new_comment = CommentSaveToDB(user=current_user.id, text=new_comment.text, recipe_slug=slug)
return db.comments.create(session, new_comment)
@router.put("/recipes/{slug}/comments/{id}")
async def update_comment(
id: int,
new_comment: CommentIn,
session: Session = Depends(generate_session),
current_user: UserInDB = Depends(get_current_user),
):
""" Update comment in the Database """
old_comment: CommentOut = db.comments.get(session, id)
if current_user.id != old_comment.user.id:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
return db.comments.update(session, id, new_comment)
@router.delete("/recipes/{slug}/comments/{id}")
async def delete_comment(
id: int, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)
):
""" Delete comment from the Database """
comment: CommentOut = db.comments.get(session, id)
print(current_user.id, comment.user.id, current_user.admin)
if current_user.id == comment.user.id or current_user.admin:
db.comments.delete(session, id)
return
raise HTTPException(status.HTTP_401_UNAUTHORIZED)

44
mealie/schema/comments.py Normal file
View File

@ -0,0 +1,44 @@
from datetime import datetime
from typing import Optional
from fastapi_camelcase import CamelModel
from pydantic.utils import GetterDict
class UserBase(CamelModel):
id: int
username: Optional[str]
admin: bool
class Config:
orm_mode = True
class CommentIn(CamelModel):
text: str
class CommentSaveToDB(CommentIn):
recipe_slug: str
user: int
class Config:
orm_mode = True
class CommentOut(CommentIn):
id: int
uuid: str
recipe_slug: str
date_added: datetime
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

@ -5,6 +5,7 @@ from typing import Any, Optional
from fastapi_camelcase import CamelModel
from mealie.core.config import app_dirs
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.schema.comments import CommentOut
from pydantic import BaseModel, Field, validator
from pydantic.utils import GetterDict
from slugify import slugify
@ -102,6 +103,8 @@ class Recipe(RecipeSummary):
org_url: Optional[str] = Field(None, alias="orgURL")
extras: Optional[dict] = {}
comments: Optional[list[CommentOut]] = []
@staticmethod
def directory_from_slug(slug) -> Path:
return app_dirs.RECIPE_DATA_DIR.joinpath(slug)

View File

@ -129,6 +129,9 @@ def backup_all(
db_export.export_items(all_recipes, "recipes", export_list=False, slug_folder=True)
db_export.export_templates(all_recipes)
all_comments = db.comments.get_all(session)
db_export.export_items(all_comments, "comments")
if export_settings:
all_settings = db.settings.get_all(session)
db_export.export_items(all_settings, "settings")

View File

@ -6,6 +6,7 @@ from typing import Callable
from mealie.core.config import app_dirs
from mealie.db.database import db
from mealie.schema.comments import CommentOut
from mealie.schema.event_notifications import EventNotificationIn
from mealie.schema.recipe import Recipe
from mealie.schema.restore import (
@ -85,6 +86,22 @@ class ImportDatabase:
return imports
def import_comments(self):
comment_dir: Path = self.import_dir.joinpath("comments", "comments.json")
comments = ImportDatabase.read_models_file(file_path=comment_dir, model=CommentOut)
for comment in comments:
comment: CommentOut
self.import_model(
db_table=db.comments,
model=comment,
return_model=ThemeImport,
name_attr="uuid",
search_key="uuid",
)
@staticmethod
def _recipe_migration(recipe_dict: dict) -> dict:
if recipe_dict.get("categories", False):
@ -364,6 +381,9 @@ def import_database(
if import_notifications:
notification_report = import_session.import_notifications()
if import_recipes:
import_session.import_comments()
import_session.clean_up()
return {