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:
Michael Genson
2023-04-25 12:46:00 -05:00
committed by GitHub
parent 0e397b34fd
commit fe17922bb8
28 changed files with 871 additions and 506 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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