mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-05 15:33:10 -05:00
feat: Recipe Timeline Images (#2444)
* refactored recipe image paths/service * added routes for updating/fetching timeline images * make generate * added event image upload and rendering * switched update to patch to preserve timestamp * added tests * tweaked order of requests * always reload events when opening the timeline * re-arranged elements to make them look nicer * delete files when timeline event is deleted
This commit is contained in:
@@ -98,14 +98,22 @@ class HttpRepo(Generic[C, R, U]):
|
||||
|
||||
return item
|
||||
|
||||
def patch_one(self, data: U, item_id: int | str | UUID4) -> None:
|
||||
self.repo.get_one(item_id)
|
||||
def patch_one(self, data: U, item_id: int | str | UUID4) -> R:
|
||||
item = self.repo.get_one(item_id)
|
||||
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail=ErrorResponse.respond(message="Not found."),
|
||||
)
|
||||
|
||||
try:
|
||||
self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True))
|
||||
item = self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True))
|
||||
except Exception as ex:
|
||||
self.handle_exception(ex)
|
||||
|
||||
return item
|
||||
|
||||
def delete_one(self, item_id: int | str | UUID4) -> R | None:
|
||||
item: R | None = None
|
||||
try:
|
||||
|
||||
@@ -5,6 +5,7 @@ from pydantic import UUID4
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventOut
|
||||
|
||||
"""
|
||||
These routes are for development only! These assets are served by Caddy when not
|
||||
@@ -23,7 +24,7 @@ class ImageType(str, Enum):
|
||||
@router.get("/{recipe_id}/images/{file_name}")
|
||||
async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.original):
|
||||
"""
|
||||
Takes in a recipe recipe_id, returns the static image. This route is proxied in the docker image
|
||||
Takes in a recipe id, returns the static image. This route is proxied in the docker image
|
||||
and should not hit the API in production
|
||||
"""
|
||||
recipe_image = Recipe.directory_from_id(recipe_id).joinpath("images", file_name.value)
|
||||
@@ -34,6 +35,24 @@ async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.origin
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@router.get("/{recipe_id}/images/timeline/{timeline_event_id}/{file_name}")
|
||||
async def get_recipe_timeline_event_img(
|
||||
recipe_id: str, timeline_event_id: str, file_name: ImageType = ImageType.original
|
||||
):
|
||||
"""
|
||||
Takes in a recipe id and event timeline id, returns the static image. This route is proxied in the docker image
|
||||
and should not hit the API in production
|
||||
"""
|
||||
timeline_event_image = RecipeTimelineEventOut.image_dir_from_id(recipe_id, timeline_event_id).joinpath(
|
||||
file_name.value
|
||||
)
|
||||
|
||||
if timeline_event_image.exists():
|
||||
return FileResponse(timeline_event_image)
|
||||
else:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@router.get("/{recipe_id}/assets/{file_name}")
|
||||
async def get_recipe_asset(recipe_id: UUID4, file_name: str):
|
||||
"""Returns a recipe asset"""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import shutil
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi import Depends, File, Form, HTTPException
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base import BaseCrudController, controller
|
||||
@@ -12,10 +13,13 @@ from mealie.schema.recipe.recipe_timeline_events import (
|
||||
RecipeTimelineEventOut,
|
||||
RecipeTimelineEventPagination,
|
||||
RecipeTimelineEventUpdate,
|
||||
TimelineEventImage,
|
||||
)
|
||||
from mealie.schema.recipe.request_helpers import UpdateImageResponse
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.services import urls
|
||||
from mealie.services.event_bus_service.event_types import EventOperation, EventRecipeTimelineEventData, EventTypes
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
|
||||
events_router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/timeline/events")
|
||||
|
||||
@@ -80,7 +84,7 @@ class RecipeTimelineEventsController(BaseCrudController):
|
||||
|
||||
@events_router.put("/{item_id}", response_model=RecipeTimelineEventOut)
|
||||
def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate):
|
||||
event = self.mixins.update_one(data, item_id)
|
||||
event = self.mixins.patch_one(data, item_id)
|
||||
recipe = self.recipes_repo.get_one(event.recipe_id, "id")
|
||||
if recipe:
|
||||
self.publish_event(
|
||||
@@ -100,6 +104,12 @@ class RecipeTimelineEventsController(BaseCrudController):
|
||||
@events_router.delete("/{item_id}", response_model=RecipeTimelineEventOut)
|
||||
def delete_one(self, item_id: UUID4):
|
||||
event = self.mixins.delete_one(item_id)
|
||||
if event.image_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(event.image_dir)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
recipe = self.recipes_repo.get_one(event.recipe_id, "id")
|
||||
if recipe:
|
||||
self.publish_event(
|
||||
@@ -115,3 +125,31 @@ class RecipeTimelineEventsController(BaseCrudController):
|
||||
)
|
||||
|
||||
return event
|
||||
|
||||
# ==================================================================================================================
|
||||
# Image and Assets
|
||||
|
||||
@events_router.put("/{item_id}/image", response_model=UpdateImageResponse)
|
||||
def update_event_image(self, item_id: UUID4, image: bytes = File(...), extension: str = Form(...)):
|
||||
event = self.mixins.get_one(item_id)
|
||||
data_service = RecipeDataService(event.recipe_id)
|
||||
data_service.write_image(image, extension, event.image_dir)
|
||||
|
||||
if event.image != TimelineEventImage.has_image.value:
|
||||
event.image = TimelineEventImage.has_image
|
||||
event = self.mixins.patch_one(event.cast(RecipeTimelineEventUpdate), event.id)
|
||||
recipe = self.recipes_repo.get_one(event.recipe_id, "id")
|
||||
if recipe:
|
||||
self.publish_event(
|
||||
event_type=EventTypes.recipe_updated,
|
||||
document_data=EventRecipeTimelineEventData(
|
||||
operation=EventOperation.update, recipe_slug=recipe.slug, recipe_timeline_event_id=event.id
|
||||
),
|
||||
message=self.t(
|
||||
"notifications.generic-updated-with-url",
|
||||
name=recipe.name,
|
||||
url=urls.recipe_url(recipe.slug, self.settings.BASE_URL),
|
||||
),
|
||||
)
|
||||
|
||||
return UpdateImageResponse(image=TimelineEventImage.has_image.value)
|
||||
|
||||
@@ -77,6 +77,7 @@ from .recipe_timeline_events import (
|
||||
RecipeTimelineEventOut,
|
||||
RecipeTimelineEventPagination,
|
||||
RecipeTimelineEventUpdate,
|
||||
TimelineEventImage,
|
||||
TimelineEventType,
|
||||
)
|
||||
from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave
|
||||
@@ -155,6 +156,7 @@ __all__ = [
|
||||
"RecipeTimelineEventOut",
|
||||
"RecipeTimelineEventPagination",
|
||||
"RecipeTimelineEventUpdate",
|
||||
"TimelineEventImage",
|
||||
"TimelineEventType",
|
||||
"RecipeToolCreate",
|
||||
"RecipeToolOut",
|
||||
|
||||
@@ -129,29 +129,48 @@ class Recipe(RecipeSummary):
|
||||
comments: list[RecipeCommentOut] | None = []
|
||||
|
||||
@staticmethod
|
||||
def directory_from_id(recipe_id: UUID4 | str) -> Path:
|
||||
return app_dirs.RECIPE_DATA_DIR.joinpath(str(recipe_id))
|
||||
def _get_dir(dir: Path) -> Path:
|
||||
"""Gets a directory and creates it if it doesn't exist"""
|
||||
|
||||
dir.mkdir(exist_ok=True, parents=True)
|
||||
return dir
|
||||
|
||||
@classmethod
|
||||
def directory_from_id(cls, recipe_id: UUID4 | str) -> Path:
|
||||
return cls._get_dir(app_dirs.RECIPE_DATA_DIR.joinpath(str(recipe_id)))
|
||||
|
||||
@classmethod
|
||||
def asset_dir_from_id(cls, recipe_id: UUID4 | str) -> Path:
|
||||
return cls._get_dir(cls.directory_from_id(recipe_id).joinpath("assets"))
|
||||
|
||||
@classmethod
|
||||
def image_dir_from_id(cls, recipe_id: UUID4 | str) -> Path:
|
||||
return cls._get_dir(cls.directory_from_id(recipe_id).joinpath("images"))
|
||||
|
||||
@classmethod
|
||||
def timeline_image_dir_from_id(cls, recipe_id: UUID4 | str, timeline_event_id: UUID4 | str) -> Path:
|
||||
return cls._get_dir(cls.image_dir_from_id(recipe_id).joinpath("timeline").joinpath(str(timeline_event_id)))
|
||||
|
||||
@property
|
||||
def directory(self) -> Path:
|
||||
if not self.id:
|
||||
raise ValueError("Recipe has no ID")
|
||||
|
||||
folder = app_dirs.RECIPE_DATA_DIR.joinpath(str(self.id))
|
||||
folder.mkdir(exist_ok=True, parents=True)
|
||||
return folder
|
||||
return self.directory_from_id(self.id)
|
||||
|
||||
@property
|
||||
def asset_dir(self) -> Path:
|
||||
folder = self.directory.joinpath("assets")
|
||||
folder.mkdir(exist_ok=True, parents=True)
|
||||
return folder
|
||||
if not self.id:
|
||||
raise ValueError("Recipe has no ID")
|
||||
|
||||
return self.asset_dir_from_id(self.id)
|
||||
|
||||
@property
|
||||
def image_dir(self) -> Path:
|
||||
folder = self.directory.joinpath("images")
|
||||
folder.mkdir(exist_ok=True, parents=True)
|
||||
return folder
|
||||
if not self.id:
|
||||
raise ValueError("Recipe has no ID")
|
||||
|
||||
return self.image_dir_from_id(self.id)
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import UUID4, Field
|
||||
|
||||
from mealie.core.config import get_app_dirs
|
||||
from mealie.schema._mealie.mealie_model import MealieModel
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
|
||||
app_dirs = get_app_dirs()
|
||||
|
||||
|
||||
class TimelineEventType(Enum):
|
||||
system = "system"
|
||||
@@ -13,6 +18,11 @@ class TimelineEventType(Enum):
|
||||
comment = "comment"
|
||||
|
||||
|
||||
class TimelineEventImage(Enum):
|
||||
has_image = "has image"
|
||||
does_not_have_image = "does not have image"
|
||||
|
||||
|
||||
class RecipeTimelineEventIn(MealieModel):
|
||||
recipe_id: UUID4
|
||||
user_id: UUID4 | None = None
|
||||
@@ -22,7 +32,7 @@ class RecipeTimelineEventIn(MealieModel):
|
||||
event_type: TimelineEventType
|
||||
|
||||
message: str | None = Field(None, alias="eventMessage")
|
||||
image: str | None = None
|
||||
image: TimelineEventImage | None = TimelineEventImage.does_not_have_image
|
||||
|
||||
timestamp: datetime = datetime.now()
|
||||
|
||||
@@ -37,7 +47,10 @@ class RecipeTimelineEventCreate(RecipeTimelineEventIn):
|
||||
class RecipeTimelineEventUpdate(MealieModel):
|
||||
subject: str
|
||||
message: str | None = Field(alias="eventMessage")
|
||||
image: str | None = None
|
||||
image: TimelineEventImage | None = None
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class RecipeTimelineEventOut(RecipeTimelineEventCreate):
|
||||
@@ -48,6 +61,14 @@ class RecipeTimelineEventOut(RecipeTimelineEventCreate):
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@classmethod
|
||||
def image_dir_from_id(cls, recipe_id: UUID4 | str, timeline_event_id: UUID4 | str) -> Path:
|
||||
return Recipe.timeline_image_dir_from_id(recipe_id, timeline_event_id)
|
||||
|
||||
@property
|
||||
def image_dir(self) -> Path:
|
||||
return self.image_dir_from_id(self.recipe_id, self.id)
|
||||
|
||||
|
||||
class RecipeTimelineEventPagination(PaginationBase):
|
||||
items: list[RecipeTimelineEventOut]
|
||||
|
||||
@@ -65,10 +65,11 @@ class RecipeDataService(BaseService):
|
||||
|
||||
self.dir_data = Recipe.directory_from_id(self.recipe_id)
|
||||
self.dir_image = self.dir_data.joinpath("images")
|
||||
self.dir_image_timeline = self.dir_image.joinpath("timeline")
|
||||
self.dir_assets = self.dir_data.joinpath("assets")
|
||||
|
||||
self.dir_image.mkdir(parents=True, exist_ok=True)
|
||||
self.dir_assets.mkdir(parents=True, exist_ok=True)
|
||||
for dir in [self.dir_image, self.dir_image_timeline, self.dir_assets]:
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def delete_all_data(self) -> None:
|
||||
try:
|
||||
@@ -76,9 +77,12 @@ class RecipeDataService(BaseService):
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Failed to delete recipe data: {e}")
|
||||
|
||||
def write_image(self, file_data: bytes | Path, extension: str) -> Path:
|
||||
def write_image(self, file_data: bytes | Path, extension: str, image_dir: Path | None = None) -> Path:
|
||||
if not image_dir:
|
||||
image_dir = self.dir_image
|
||||
|
||||
extension = extension.replace(".", "")
|
||||
image_path = self.dir_image.joinpath(f"original.{extension}")
|
||||
image_path = image_dir.joinpath(f"original.{extension}")
|
||||
image_path.unlink(missing_ok=True)
|
||||
|
||||
if isinstance(file_data, Path):
|
||||
|
||||
Reference in New Issue
Block a user