mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-13 05:45:22 -05:00
fix(backend): 🐛 Fix recipe page issues (#778)
* fix(backend): 🐛 Fix favorite assignment on backend * fix(frontend): 🐛 fix printer button on recipe page * style(frontend): 🚸 add user feadback on copy of recipe link * fix(frontend): 🐛 Fix enableLandscape incorrect bindings to remove duplicate values * feat(frontend): ✨ add ingredient copy button for markdown list -[ ] format * feat(frontend): ✨ add remove prefix button to bulk entry * fix(frontend): 🐛 disable random button when no recipes are present * fix(frontend): ✨ fix .zip download error * fix(frontend): 🚸 close image dialog on upload/get * fix(frontend): 🐛 fix assignment on creation for categories and tags * feat(frontend): ✨ Open editor on creation / fix edit button on main screen * fix(frontend): 🐛 fix false negative regex match for urls on creationg page * feat(frontend): 🚸 provide better user feadback when recipe exists * feat(frontend): ✨ lock bulk importer on submit * remove zip from navigation * fix(frontend): ✨ rerender recipes on delete Co-authored-by: Hayden K <hay-kot@pm.me>
This commit is contained in:
@@ -115,6 +115,23 @@ def validate_file_token(token: Optional[str] = None) -> Path:
|
||||
return file_path
|
||||
|
||||
|
||||
def validate_recipe_token(token: Optional[str] = None) -> str:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="could not validate file token",
|
||||
)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
|
||||
slug = payload.get("slug")
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
return slug
|
||||
|
||||
|
||||
async def temporary_zip_path() -> Path:
|
||||
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
|
||||
temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip")
|
||||
|
||||
@@ -25,11 +25,16 @@ def create_access_token(data: dict(), expires_delta: timedelta = None) -> str:
|
||||
return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_file_token(file_path: Path) -> bool:
|
||||
def create_file_token(file_path: Path) -> str:
|
||||
token_data = {"file": str(file_path)}
|
||||
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
|
||||
|
||||
|
||||
def create_recipe_slug_token(file_path: str) -> str:
|
||||
token_data = {"slug": str(file_path)}
|
||||
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
|
||||
|
||||
|
||||
def authenticate_user(session, email: str, password: str) -> PrivateUser:
|
||||
db = get_database(session)
|
||||
|
||||
|
||||
@@ -74,23 +74,23 @@ def handle_many_to_many(session, get_attr, relation_cls, all_elements: list[dict
|
||||
return handle_one_to_many_list(session, get_attr, relation_cls, all_elements)
|
||||
|
||||
|
||||
def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elements: list[dict]):
|
||||
def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elements: list[dict] | list[str]):
|
||||
elems_to_create: list[dict] = []
|
||||
updated_elems: list[dict] = []
|
||||
|
||||
for elem in all_elements:
|
||||
elem_id = elem.get(get_attr, None)
|
||||
|
||||
elem_id = elem.get(get_attr, None) if isinstance(elem, dict) else elem
|
||||
existing_elem = session.query(relation_cls).filter_by(**{get_attr: elem_id}).one_or_none()
|
||||
|
||||
if existing_elem is None:
|
||||
elems_to_create.append(elem)
|
||||
continue
|
||||
|
||||
else:
|
||||
elif isinstance(elem, dict):
|
||||
for key, value in elem.items():
|
||||
setattr(existing_elem, key, value)
|
||||
|
||||
updated_elems.append(existing_elem)
|
||||
updated_elems.append(existing_elem)
|
||||
|
||||
new_elems = [safe_call(relation_cls, elem) for elem in elems_to_create]
|
||||
return new_elems + updated_elems
|
||||
|
||||
@@ -3,6 +3,7 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
||||
from mealie.core.config import get_app_settings
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from ..group import Group
|
||||
from .user_to_favorite import users_to_favorites
|
||||
|
||||
@@ -56,41 +57,37 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session,
|
||||
full_name,
|
||||
email,
|
||||
password,
|
||||
favorite_recipes: list[str] = None,
|
||||
group: str = settings.DEFAULT_GROUP,
|
||||
advanced=False,
|
||||
**kwargs
|
||||
) -> None:
|
||||
group = group or settings.DEFAULT_GROUP
|
||||
favorite_recipes = favorite_recipes or []
|
||||
class Config:
|
||||
exclude = {
|
||||
"password",
|
||||
"admin",
|
||||
"can_manage",
|
||||
"can_invite",
|
||||
"can_organize",
|
||||
"group",
|
||||
"username",
|
||||
}
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, session, full_name, password, group: str = settings.DEFAULT_GROUP, **kwargs) -> None:
|
||||
self.group = Group.get_ref(session, group)
|
||||
|
||||
self.full_name = full_name
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.advanced = advanced
|
||||
|
||||
self.favorite_recipes = []
|
||||
|
||||
self.password = password
|
||||
|
||||
if self.username is None:
|
||||
self.username = full_name
|
||||
|
||||
self._set_permissions(**kwargs)
|
||||
|
||||
def update(self, full_name, email, group, username, session=None, favorite_recipes=None, advanced=False, **kwargs):
|
||||
favorite_recipes = favorite_recipes or []
|
||||
@auto_init()
|
||||
def update(self, full_name, email, group, username, session=None, **kwargs):
|
||||
self.username = username
|
||||
self.full_name = full_name
|
||||
self.email = email
|
||||
|
||||
self.group = Group.get_ref(session, group)
|
||||
self.advanced = advanced
|
||||
|
||||
if self.username is None:
|
||||
self.username = full_name
|
||||
|
||||
@@ -8,6 +8,7 @@ router = APIRouter()
|
||||
|
||||
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
|
||||
router.include_router(recipe_export.user_router, prefix=prefix, tags=["Recipe: Exports"])
|
||||
router.include_router(recipe_export.public_router, prefix=prefix, tags=["Recipe: Exports"])
|
||||
router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Recipe: CRUD"])
|
||||
router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"])
|
||||
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
from zipfile import ZipFile
|
||||
|
||||
from fastapi import Depends, File
|
||||
from fastapi.datastructures import UploadFile
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm.session import Session
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from mealie.core.dependencies import temporary_zip_path
|
||||
from mealie.core.root_logger import get_logger
|
||||
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 CreateRecipeByUrl, Recipe, RecipeImageTypes
|
||||
from mealie.schema.recipe import CreateRecipeByUrl, Recipe
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary
|
||||
from mealie.schema.server.tasks import ServerTaskNames
|
||||
from mealie.services.recipe.recipe_service import RecipeService
|
||||
@@ -109,23 +105,6 @@ def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existi
|
||||
return recipe_service.item
|
||||
|
||||
|
||||
@user_router.get("/{slug}/zip")
|
||||
async def get_recipe_as_zip(
|
||||
slug: str, session: Session = Depends(generate_session), temp_path=Depends(temporary_zip_path)
|
||||
):
|
||||
""" Get a Recipe and It's Original Image as a Zip File """
|
||||
db = get_database(session)
|
||||
recipe: Recipe = db.recipes.get(slug)
|
||||
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
|
||||
with ZipFile(temp_path, "w") as myzip:
|
||||
myzip.writestr(f"{slug}.json", recipe.json())
|
||||
|
||||
if image_asset.is_file():
|
||||
myzip.write(image_asset, arcname=image_asset.name)
|
||||
|
||||
return FileResponse(temp_path, filename=f"{slug}.zip")
|
||||
|
||||
|
||||
@user_router.put("/{slug}")
|
||||
def update_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
|
||||
""" Updates a recipe by existing slug and data. """
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
from fastapi import Depends
|
||||
from zipfile import ZipFile
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm.session import Session
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from mealie.core.dependencies.dependencies import temporary_dir
|
||||
from mealie.core.dependencies import temporary_zip_path
|
||||
from mealie.core.dependencies.dependencies import temporary_dir, validate_recipe_token
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.core.security import create_recipe_slug_token
|
||||
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 Recipe, RecipeImageTypes
|
||||
from mealie.services.recipe.recipe_service import RecipeService
|
||||
from mealie.services.recipe.template_service import TemplateService
|
||||
|
||||
user_router = UserAPIRouter()
|
||||
public_router = APIRouter()
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@@ -23,6 +32,12 @@ async def get_recipe_formats_and_templates(_: RecipeService = Depends(RecipeServ
|
||||
return TemplateService().templates
|
||||
|
||||
|
||||
@user_router.post("/{slug}/exports")
|
||||
async def get_recipe_zip_token(slug: str):
|
||||
""" Generates a recipe zip token to be used to download a recipe as a zip file """
|
||||
return {"token": create_recipe_slug_token(slug)}
|
||||
|
||||
|
||||
@user_router.get("/{slug}/exports", response_class=FileResponse)
|
||||
def get_recipe_as_format(
|
||||
template_name: str,
|
||||
@@ -38,3 +53,28 @@ def get_recipe_as_format(
|
||||
"""
|
||||
file = recipe_service.render_template(temp_dir, template_name)
|
||||
return FileResponse(file)
|
||||
|
||||
|
||||
@public_router.get("/{slug}/exports/zip")
|
||||
async def get_recipe_as_zip(
|
||||
token: str,
|
||||
slug: str,
|
||||
session: Session = Depends(generate_session),
|
||||
temp_path=Depends(temporary_zip_path),
|
||||
):
|
||||
""" Get a Recipe and It's Original Image as a Zip File """
|
||||
slug = validate_recipe_token(token)
|
||||
|
||||
if slug != slug:
|
||||
raise HTTPException(status_code=400, detail="Invalid Slug")
|
||||
|
||||
db = get_database(session)
|
||||
recipe: Recipe = db.recipes.get(slug)
|
||||
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
|
||||
with ZipFile(temp_path, "w") as myzip:
|
||||
myzip.writestr(f"{slug}.json", recipe.json())
|
||||
|
||||
if image_asset.is_file():
|
||||
myzip.write(image_asset, arcname=image_asset.name)
|
||||
|
||||
return FileResponse(temp_path, filename=f"{slug}.zip")
|
||||
|
||||
@@ -26,9 +26,7 @@ def add_favorite(
|
||||
):
|
||||
""" Adds a Recipe to the users favorites """
|
||||
|
||||
assert_user_change_allowed(id, current_user)
|
||||
current_user.favorite_recipes.append(slug)
|
||||
|
||||
db = get_database(session)
|
||||
db.users.update(current_user.id, current_user)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user