mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-14 06:15:26 -05:00
Feature: Global Timeline (#2265)
* extended query filter to accept nested tables * decoupled timeline api from recipe slug * modified frontend to use simplified events api * fixed nested loop index ghosting * updated existing tests * gave mypy a snack * added tests for nested queries * fixed "last made" render error * decoupled recipe timeline from dialog * removed unused props * tweaked recipe get_all to accept ids * created group global timeline added new timeline page to sidebar reformatted the recipe timeline added vertical option to recipe card mobile * extracted timeline item into its own component * fixed apploader centering * added paginated scrolling to recipe timeline * added sort direction config fixed infinite scroll on dialog fixed hasMore var not resetting during instantiation * added sort direction to user preferences * updated API docs with new query filter feature * better error tracing * fix for recipe not found response * simplified recipe crud route for slug/id added test for fetching by slug/id * made query filter UUID validation clearer * moved timeline menu option below shopping lists --------- Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
@@ -4,7 +4,7 @@ from zipfile import ZipFile
|
||||
|
||||
import orjson
|
||||
import sqlalchemy
|
||||
from fastapi import BackgroundTasks, Depends, File, Form, HTTPException, Query, Request, status
|
||||
from fastapi import BackgroundTasks, Depends, File, Form, HTTPException, Path, Query, Request, status
|
||||
from fastapi.datastructures import UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import UUID4, BaseModel, Field
|
||||
@@ -24,12 +24,7 @@ from mealie.routes._base.mixins import HttpRepo
|
||||
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe
|
||||
from mealie.schema.recipe.recipe import (
|
||||
CreateRecipe,
|
||||
CreateRecipeByUrlBulk,
|
||||
RecipeLastMade,
|
||||
RecipeSummary,
|
||||
)
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeLastMade, RecipeSummary
|
||||
from mealie.schema.recipe.recipe_asset import RecipeAsset
|
||||
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
|
||||
from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
|
||||
@@ -284,9 +279,15 @@ class RecipeController(BaseRecipeController):
|
||||
return JSONBytes(content=json_compatible_response)
|
||||
|
||||
@router.get("/{slug}", response_model=Recipe)
|
||||
def get_one(self, slug: str):
|
||||
"""Takes in a recipe slug, returns all data for a recipe"""
|
||||
return self.mixins.get_one(slug)
|
||||
def get_one(self, slug: str = Path(..., description="A recipe's slug or id")):
|
||||
"""Takes in a recipe's slug or id and returns all data for a recipe"""
|
||||
try:
|
||||
recipe = self.service.get_one_by_slug_or_id(slug)
|
||||
except Exception as e:
|
||||
self.handle_exceptions(e)
|
||||
return None
|
||||
|
||||
return recipe
|
||||
|
||||
@router.post("", status_code=201, response_model=str)
|
||||
def create_one(self, data: CreateRecipe) -> str | None:
|
||||
|
||||
@@ -6,7 +6,6 @@ from pydantic import UUID4
|
||||
from mealie.routes._base import BaseCrudController, controller
|
||||
from mealie.routes._base.mixins import HttpRepo
|
||||
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe_timeline_events import (
|
||||
RecipeTimelineEventCreate,
|
||||
RecipeTimelineEventIn,
|
||||
@@ -18,7 +17,7 @@ from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.services import urls
|
||||
from mealie.services.event_bus_service.event_types import EventOperation, EventRecipeTimelineEventData, EventTypes
|
||||
|
||||
events_router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/{slug}/timeline/events")
|
||||
events_router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/timeline/events")
|
||||
|
||||
|
||||
@controller(events_router)
|
||||
@@ -27,6 +26,10 @@ class RecipeTimelineEventsController(BaseCrudController):
|
||||
def repo(self):
|
||||
return self.repos.recipe_timeline_events
|
||||
|
||||
@cached_property
|
||||
def recipes_repo(self):
|
||||
return self.repos.recipes.by_group(self.group_id)
|
||||
|
||||
@cached_property
|
||||
def mixins(self):
|
||||
return HttpRepo[RecipeTimelineEventCreate, RecipeTimelineEventOut, RecipeTimelineEventUpdate](
|
||||
@@ -35,39 +38,26 @@ class RecipeTimelineEventsController(BaseCrudController):
|
||||
self.registered_exceptions,
|
||||
)
|
||||
|
||||
def get_recipe_from_slug(self, slug: str) -> Recipe:
|
||||
recipe = self.repos.recipes.by_group(self.group_id).get_one(slug)
|
||||
if not recipe or self.group_id != recipe.group_id:
|
||||
raise HTTPException(status_code=404, detail="recipe not found")
|
||||
|
||||
return recipe
|
||||
|
||||
@events_router.get("", response_model=RecipeTimelineEventPagination)
|
||||
def get_all(self, slug: str, q: PaginationQuery = Depends(PaginationQuery)):
|
||||
recipe = self.get_recipe_from_slug(slug)
|
||||
recipe_filter = f"recipe_id = {recipe.id}"
|
||||
|
||||
if q.query_filter:
|
||||
q.query_filter = f"({q.query_filter}) AND {recipe_filter}"
|
||||
|
||||
else:
|
||||
q.query_filter = recipe_filter
|
||||
|
||||
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
|
||||
response = self.repo.page_all(
|
||||
pagination=q,
|
||||
override=RecipeTimelineEventOut,
|
||||
)
|
||||
|
||||
response.set_pagination_guides(events_router.url_path_for("get_all", slug=slug), q.dict())
|
||||
response.set_pagination_guides(events_router.url_path_for("get_all"), q.dict())
|
||||
return response
|
||||
|
||||
@events_router.post("", response_model=RecipeTimelineEventOut, status_code=201)
|
||||
def create_one(self, slug: str, data: RecipeTimelineEventIn):
|
||||
def create_one(self, data: RecipeTimelineEventIn):
|
||||
# if the user id is not specified, use the currently-authenticated user
|
||||
data.user_id = data.user_id or self.user.id
|
||||
|
||||
recipe = self.get_recipe_from_slug(slug)
|
||||
event_data = data.cast(RecipeTimelineEventCreate, recipe_id=recipe.id)
|
||||
recipe = self.recipes_repo.get_one(data.recipe_id, "id")
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=404, detail="recipe not found")
|
||||
|
||||
event_data = data.cast(RecipeTimelineEventCreate)
|
||||
event = self.mixins.create_one(event_data)
|
||||
|
||||
self.publish_event(
|
||||
@@ -78,69 +68,50 @@ class RecipeTimelineEventsController(BaseCrudController):
|
||||
message=self.t(
|
||||
"notifications.generic-updated-with-url",
|
||||
name=recipe.name,
|
||||
url=urls.recipe_url(slug, self.settings.BASE_URL),
|
||||
url=urls.recipe_url(recipe.slug, self.settings.BASE_URL),
|
||||
),
|
||||
)
|
||||
|
||||
return event
|
||||
|
||||
@events_router.get("/{item_id}", response_model=RecipeTimelineEventOut)
|
||||
def get_one(self, slug: str, item_id: UUID4):
|
||||
recipe = self.get_recipe_from_slug(slug)
|
||||
event = self.mixins.get_one(item_id)
|
||||
|
||||
# validate that this event belongs to the given recipe slug
|
||||
if event.recipe_id != recipe.id:
|
||||
raise HTTPException(status_code=404, detail="recipe event not found")
|
||||
|
||||
return event
|
||||
def get_one(self, item_id: UUID4):
|
||||
return self.mixins.get_one(item_id)
|
||||
|
||||
@events_router.put("/{item_id}", response_model=RecipeTimelineEventOut)
|
||||
def update_one(self, slug: str, item_id: UUID4, data: RecipeTimelineEventUpdate):
|
||||
recipe = self.get_recipe_from_slug(slug)
|
||||
event = self.mixins.get_one(item_id)
|
||||
|
||||
# validate that this event belongs to the given recipe slug
|
||||
if event.recipe_id != recipe.id:
|
||||
raise HTTPException(status_code=404, detail="recipe event not found")
|
||||
|
||||
def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate):
|
||||
event = self.mixins.update_one(data, item_id)
|
||||
|
||||
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(slug, self.settings.BASE_URL),
|
||||
),
|
||||
)
|
||||
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 event
|
||||
|
||||
@events_router.delete("/{item_id}", response_model=RecipeTimelineEventOut)
|
||||
def delete_one(self, slug: str, item_id: UUID4):
|
||||
recipe = self.get_recipe_from_slug(slug)
|
||||
event = self.mixins.get_one(item_id)
|
||||
|
||||
# validate that this event belongs to the given recipe slug
|
||||
if event.recipe_id != recipe.id:
|
||||
raise HTTPException(status_code=404, detail="recipe event not found")
|
||||
|
||||
def delete_one(self, item_id: UUID4):
|
||||
event = self.mixins.delete_one(item_id)
|
||||
|
||||
self.publish_event(
|
||||
event_type=EventTypes.recipe_updated,
|
||||
document_data=EventRecipeTimelineEventData(
|
||||
operation=EventOperation.delete, 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(slug, self.settings.BASE_URL),
|
||||
),
|
||||
)
|
||||
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.delete, 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 event
|
||||
|
||||
@@ -14,6 +14,7 @@ class TimelineEventType(Enum):
|
||||
|
||||
|
||||
class RecipeTimelineEventIn(MealieModel):
|
||||
recipe_id: UUID4
|
||||
user_id: UUID4 | None = None
|
||||
"""can be inferred in some contexts, so it's not required"""
|
||||
|
||||
@@ -30,7 +31,6 @@ class RecipeTimelineEventIn(MealieModel):
|
||||
|
||||
|
||||
class RecipeTimelineEventCreate(RecipeTimelineEventIn):
|
||||
recipe_id: UUID4
|
||||
user_id: UUID4
|
||||
|
||||
|
||||
|
||||
@@ -4,14 +4,18 @@ import datetime
|
||||
import re
|
||||
from enum import Enum
|
||||
from typing import Any, TypeVar, cast
|
||||
from uuid import UUID
|
||||
|
||||
from dateutil import parser as date_parser
|
||||
from dateutil.parser import ParserError
|
||||
from humps import decamelize
|
||||
from sqlalchemy import Select, bindparam, text
|
||||
from sqlalchemy import Select, bindparam, inspect, text
|
||||
from sqlalchemy.orm import Mapper
|
||||
from sqlalchemy.sql import sqltypes
|
||||
from sqlalchemy.sql.expression import BindParameter
|
||||
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
Model = TypeVar("Model")
|
||||
|
||||
|
||||
@@ -87,14 +91,51 @@ class QueryFilter:
|
||||
# we explicitly mark this as a filter component instead cast doesn't
|
||||
# actually do anything at runtime
|
||||
component = cast(QueryFilterComponent, component)
|
||||
attribute_chain = component.attribute_name.split(".")
|
||||
if not attribute_chain:
|
||||
raise ValueError("invalid query string: attribute name cannot be empty")
|
||||
|
||||
if not hasattr(model, component.attribute_name):
|
||||
raise ValueError(f"invalid query string: '{component.attribute_name}' does not exist on this schema")
|
||||
attr_model: Any = model
|
||||
for j, attribute_link in enumerate(attribute_chain):
|
||||
# last element
|
||||
if j == len(attribute_chain) - 1:
|
||||
if not hasattr(attr_model, attribute_link):
|
||||
raise ValueError(
|
||||
f"invalid query string: '{component.attribute_name}' does not exist on this schema"
|
||||
)
|
||||
|
||||
attr_value = attribute_link
|
||||
if j:
|
||||
# use the nested table name, rather than the dot notation
|
||||
component.attribute_name = f"{attr_model.__table__.name}.{attr_value}"
|
||||
|
||||
continue
|
||||
|
||||
# join on nested model
|
||||
try:
|
||||
query = query.join(getattr(attr_model, attribute_link))
|
||||
|
||||
mapper: Mapper = inspect(attr_model)
|
||||
relationship = mapper.relationships[attribute_link]
|
||||
attr_model = relationship.mapper.class_
|
||||
|
||||
except (AttributeError, KeyError) as e:
|
||||
raise ValueError(
|
||||
f"invalid query string: '{component.attribute_name}' does not exist on this schema"
|
||||
) from e
|
||||
|
||||
# convert values to their proper types
|
||||
attr = getattr(model, component.attribute_name)
|
||||
attr = getattr(attr_model, attr_value)
|
||||
value: Any = component.value
|
||||
|
||||
if isinstance(attr.type, (GUID)):
|
||||
try:
|
||||
# we don't set value since a UUID is functionally identical to a string here
|
||||
UUID(value)
|
||||
|
||||
except ValueError as e:
|
||||
raise ValueError(f"invalid query string: invalid UUID '{component.value}'") from e
|
||||
|
||||
if isinstance(attr.type, (sqltypes.Date, sqltypes.DateTime)):
|
||||
# TODO: add support for IS NULL and IS NOT NULL
|
||||
# in the meantime, this will work for the specific usecase of non-null dates/datetimes
|
||||
|
||||
@@ -3,7 +3,7 @@ import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from shutil import copytree, rmtree
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
from zipfile import ZipFile
|
||||
|
||||
from fastapi import UploadFile
|
||||
@@ -42,8 +42,8 @@ class RecipeService(BaseService):
|
||||
self.group = group
|
||||
super().__init__()
|
||||
|
||||
def _get_recipe(self, slug: str) -> Recipe:
|
||||
recipe = self.repos.recipes.by_group(self.group.id).get_one(slug)
|
||||
def _get_recipe(self, data: str | UUID, key: str | None = None) -> Recipe:
|
||||
recipe = self.repos.recipes.by_group(self.group.id).get_one(data, key)
|
||||
if recipe is None:
|
||||
raise exceptions.NoEntryFound("Recipe not found.")
|
||||
return recipe
|
||||
@@ -107,6 +107,19 @@ class RecipeService(BaseService):
|
||||
|
||||
return Recipe(**additional_attrs)
|
||||
|
||||
def get_one_by_slug_or_id(self, slug_or_id: str | UUID) -> Recipe | None:
|
||||
if isinstance(slug_or_id, str):
|
||||
try:
|
||||
slug_or_id = UUID(slug_or_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if isinstance(slug_or_id, UUID):
|
||||
return self._get_recipe(slug_or_id, "id")
|
||||
|
||||
else:
|
||||
return self._get_recipe(slug_or_id, "slug")
|
||||
|
||||
def create_one(self, create_data: Recipe | CreateRecipe) -> Recipe:
|
||||
if create_data.name is None:
|
||||
create_data.name = "New Recipe"
|
||||
|
||||
@@ -6,10 +6,7 @@ from mealie.db.db_setup import session_context
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.schema.meal_plan.new_meal import PlanEntryType
|
||||
from mealie.schema.recipe.recipe import RecipeSummary
|
||||
from mealie.schema.recipe.recipe_timeline_events import (
|
||||
RecipeTimelineEventCreate,
|
||||
TimelineEventType,
|
||||
)
|
||||
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.schema.user.user import DEFAULT_INTEGRATION_ID
|
||||
from mealie.services.event_bus_service.event_bus_service import EventBusService
|
||||
|
||||
Reference in New Issue
Block a user