diff --git a/dev/scripts/app_routes_gen copy.py b/dev/scripts/app_routes_gen.py similarity index 97% rename from dev/scripts/app_routes_gen copy.py rename to dev/scripts/app_routes_gen.py index 3e42f05749cf..b1df08c01188 100644 --- a/dev/scripts/app_routes_gen copy.py +++ b/dev/scripts/app_routes_gen.py @@ -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(): diff --git a/dev/scripts/output/app_routes.py b/dev/scripts/output/app_routes.py index 1bbb8e9dda91..c1e33059ac6e 100644 --- a/dev/scripts/output/app_routes.py +++ b/dev/scripts/output/app_routes.py @@ -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}" diff --git a/frontend/src/api/apiRoutes.js b/frontend/src/api/apiRoutes.js index b5d1c5b3c8ef..0102f4f0ff9f 100644 --- a/frontend/src/api/apiRoutes.js +++ b/frontend/src/api/apiRoutes.js @@ -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}`, diff --git a/frontend/src/api/recipe.js b/frontend/src/api/recipe.js index 7323ae333962..e49b05a9f49a 100644 --- a/frontend/src/api/recipe.js +++ b/frontend/src/api/recipe.js @@ -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; + }, }; diff --git a/frontend/src/components/MealPlan/MealPlanCard.vue b/frontend/src/components/MealPlan/MealPlanCard.vue index 4521c8d3415b..be8f28a7fe47 100644 --- a/frontend/src/components/MealPlan/MealPlanCard.vue +++ b/frontend/src/components/MealPlan/MealPlanCard.vue @@ -29,7 +29,7 @@ - {{ $d(new Date(planDay.date.split("-")), "short") }} + {{ $d(new Date(planDay.date.replaceAll("-", "/")), "short") }} {{ planDay.meals[0].name }} diff --git a/frontend/src/components/MealPlan/MealPlanNew.vue b/frontend/src/components/MealPlan/MealPlanNew.vue index d5bf0582868f..26b0a0259761 100644 --- a/frontend/src/components/MealPlan/MealPlanNew.vue +++ b/frontend/src/components/MealPlan/MealPlanNew.vue @@ -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); }, }, diff --git a/frontend/src/components/Recipe/CommentSection/index.vue b/frontend/src/components/Recipe/CommentSection/index.vue new file mode 100644 index 000000000000..eeac72cafeab --- /dev/null +++ b/frontend/src/components/Recipe/CommentSection/index.vue @@ -0,0 +1,121 @@ + + + + + mdi-comment-text-multiple-outline + + Comments + + + + + + + + + {{ comment.user.username }} + {{ $d(new Date(comment.dateAdded), "short") }} + + + + + + + + + + {{ !editKeys[comment.id] ? comment.text : null }} + + + + + + + + Comment + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/Recipe/FavoriteBadge.vue b/frontend/src/components/Recipe/FavoriteBadge.vue index 458fc2916752..d6de97763145 100644 --- a/frontend/src/components/Recipe/FavoriteBadge.vue +++ b/frontend/src/components/Recipe/FavoriteBadge.vue @@ -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" > diff --git a/frontend/src/pages/MealPlan/Planner.vue b/frontend/src/pages/MealPlan/Planner.vue index ff188594574e..8a0d92aad953 100644 --- a/frontend/src/pages/MealPlan/Planner.vue +++ b/frontend/src/pages/MealPlan/Planner.vue @@ -13,8 +13,8 @@ - {{ $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") }} @@ -22,7 +22,7 @@ mdi-cart-check - {{$t('shopping-list.create-shopping-list')}} + {{ $t("shopping-list.create-shopping-list") }} mdi-cart-check - {{$t('shopping-list.shopping-list')}} + {{ $t("shopping-list.shopping-list") }} - {{$t('general.link-copied')}} + + {{ $t("general.link-copied") }} + @@ -48,7 +50,9 @@ - + diff --git a/frontend/src/pages/Recipe/ViewRecipe.vue b/frontend/src/pages/Recipe/ViewRecipe.vue index c503f436d635..0be2814510a9 100644 --- a/frontend/src/pages/Recipe/ViewRecipe.vue +++ b/frontend/src/pages/Recipe/ViewRecipe.vue @@ -44,6 +44,13 @@ /> + @@ -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: { diff --git a/mealie/db/database.py b/mealie/db/database.py index 6a1b42143a7d..eb22f6c3ce3d 100644 --- a/mealie/db/database.py +++ b/mealie/db/database.py @@ -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() diff --git a/mealie/db/models/model_base.py b/mealie/db/models/model_base.py index c240f43f3cb1..d3b23d3b05b5 100644 --- a/mealie/db/models/model_base.py +++ b/mealie/db/models/model_base.py @@ -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() diff --git a/mealie/db/models/recipe/assets.py b/mealie/db/models/recipe/assets.py index 7fb1dab0a92c..0a24006230bb 100644 --- a/mealie/db/models/recipe/assets.py +++ b/mealie/db/models/recipe/assets.py @@ -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 diff --git a/mealie/db/models/recipe/comment.py b/mealie/db/models/recipe/comment.py new file mode 100644 index 000000000000..7ccdbe1c70bb --- /dev/null +++ b/mealie/db/models/recipe/comment.py @@ -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 diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 2812b6249042..45a045e28b7c 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -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") diff --git a/mealie/db/models/users.py b/mealie/db/models/users.py index 43cf42a7497a..60ce7110e7d3 100644 --- a/mealie/db/models/users.py +++ b/mealie/db/models/users.py @@ -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): diff --git a/mealie/routes/recipe/__init__.py b/mealie/routes/recipe/__init__.py index fb9a0d55bd32..cd78cf574743 100644 --- a/mealie/routes/recipe/__init__.py +++ b/mealie/routes/recipe/__init__.py @@ -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) diff --git a/mealie/routes/recipe/comments.py b/mealie/routes/recipe/comments.py new file mode 100644 index 000000000000..2adbedf39735 --- /dev/null +++ b/mealie/routes/recipe/comments.py @@ -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) diff --git a/mealie/schema/comments.py b/mealie/schema/comments.py new file mode 100644 index 000000000000..6abd8121e013 --- /dev/null +++ b/mealie/schema/comments.py @@ -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, + } diff --git a/mealie/schema/recipe.py b/mealie/schema/recipe.py index 521c6d8884fb..bd11f9f29a05 100644 --- a/mealie/schema/recipe.py +++ b/mealie/schema/recipe.py @@ -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) diff --git a/mealie/services/backups/exports.py b/mealie/services/backups/exports.py index c127ad6a397e..f2feaca58459 100644 --- a/mealie/services/backups/exports.py +++ b/mealie/services/backups/exports.py @@ -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") diff --git a/mealie/services/backups/imports.py b/mealie/services/backups/imports.py index e497a04242c1..d4f626257861 100644 --- a/mealie/services/backups/imports.py +++ b/mealie/services/backups/imports.py @@ -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 {