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