diff --git a/frontend/components/Domain/Recipe/RecipeTimeline.vue b/frontend/components/Domain/Recipe/RecipeTimeline.vue
new file mode 100644
index 00000000000..bcd854c6ab1
--- /dev/null
+++ b/frontend/components/Domain/Recipe/RecipeTimeline.vue
@@ -0,0 +1,266 @@
+
+
+
+
+
+
+ {{ preferences.orderDirection === "asc" ? $globals.icons.sortCalendarAscending : $globals.icons.sortCalendarDescending }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("recipe.timeline-is-empty") }}
+
+
+
+
+
+
+
diff --git a/frontend/components/Domain/Recipe/RecipeTimelineBadge.vue b/frontend/components/Domain/Recipe/RecipeTimelineBadge.vue
index d699f0ff396..9fd03d0eb83 100644
--- a/frontend/components/Domain/Recipe/RecipeTimelineBadge.vue
+++ b/frontend/components/Domain/Recipe/RecipeTimelineBadge.vue
@@ -14,17 +14,20 @@
{{ $globals.icons.timelineText }}
-
+
+
+
+
{{ $t('recipe.open-timeline') }}
diff --git a/frontend/components/Domain/Recipe/RecipeTimelineContextMenu.vue b/frontend/components/Domain/Recipe/RecipeTimelineContextMenu.vue
index cbd22810550..501c44819f3 100644
--- a/frontend/components/Domain/Recipe/RecipeTimelineContextMenu.vue
+++ b/frontend/components/Domain/Recipe/RecipeTimelineContextMenu.vue
@@ -113,10 +113,6 @@ export default defineComponent({
type: String,
default: "primary",
},
- slug: {
- type: String,
- required: true,
- },
event: {
type: Object as () => RecipeTimelineEventOut,
required: true,
diff --git a/frontend/components/Domain/Recipe/RecipeTimelineItem.vue b/frontend/components/Domain/Recipe/RecipeTimelineItem.vue
new file mode 100644
index 00000000000..f5674607cfd
--- /dev/null
+++ b/frontend/components/Domain/Recipe/RecipeTimelineItem.vue
@@ -0,0 +1,162 @@
+
+
+
+
+ {{ $globals.icons.calendar }}
+ {{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $globals.icons.calendar }}
+ {{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
+
+
+
+ {{ event.subject }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ event.subject }}
+
+ {{ event.eventMessage }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/components/global/AppLoader.vue b/frontend/components/global/AppLoader.vue
index 34913a249b6..c7ec8e7bc27 100644
--- a/frontend/components/global/AppLoader.vue
+++ b/frontend/components/global/AppLoader.vue
@@ -1,21 +1,23 @@
-
-
-
-
- {{ $globals.icons.primary }}
-
-
-
- {{ small ? "" : waitingText }}
-
+
+
+
+
+
+ {{ $globals.icons.primary }}
+
+
+
+ {{ small ? "" : waitingText }}
+
+
+
+
+
+ {{ small ? "" : waitingTextCalculated }}
+
-
-
-
- {{ small ? "" : waitingText }}
-
@@ -41,6 +43,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
+ waitingText: {
+ type: String,
+ default: undefined,
+ }
},
setup(props) {
const size = computed(() => {
@@ -65,11 +71,11 @@ export default defineComponent({
});
const { i18n } = useContext();
- const waitingText = i18n.t("general.loading-recipes");
+ const waitingTextCalculated = props.waitingText == null ? i18n.t("general.loading-recipes") : props.waitingText;
return {
size,
- waitingText,
+ waitingTextCalculated,
};
},
});
diff --git a/frontend/composables/use-users/preferences.ts b/frontend/composables/use-users/preferences.ts
index 471e2482e47..c93c5dcb4c0 100644
--- a/frontend/composables/use-users/preferences.ts
+++ b/frontend/composables/use-users/preferences.ts
@@ -25,6 +25,10 @@ export interface UserShoppingListPreferences {
viewByLabel: boolean;
}
+export interface UserTimelinePreferences {
+ orderDirection: string;
+}
+
export function useUserPrintPreferences(): Ref
{
const fromStorage = useLocalStorage(
"recipe-print-preferences",
@@ -75,3 +79,17 @@ export function useShoppingListPreferences(): Ref {
return fromStorage;
}
+
+export function useTimelinePreferences(): Ref {
+ const fromStorage = useLocalStorage(
+ "timeline-preferences",
+ {
+ orderDirection: "asc",
+ },
+ { mergeDefaults: true }
+ // we cast to a Ref because by default it will return an optional type ref
+ // but since we pass defaults we know all properties are set.
+ ) as unknown as Ref;
+
+ return fromStorage;
+}
diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json
index 5e240f8257c..569d95d7edb 100644
--- a/frontend/lang/messages/en-US.json
+++ b/frontend/lang/messages/en-US.json
@@ -113,6 +113,7 @@
"json": "JSON",
"keyword": "Keyword",
"link-copied": "Link Copied",
+ "loading-events": "Loading Events",
"loading-recipes": "Loading Recipes",
"message": "Message",
"monday": "Monday",
@@ -478,6 +479,7 @@
"edit-timeline-event": "Edit Timeline Event",
"timeline": "Timeline",
"timeline-is-empty": "Nothing on the timeline yet. Try making this recipe!",
+ "group-global-timeline": "{groupName} Global Timeline",
"open-timeline": "Open Timeline",
"made-this": "I Made This",
"how-did-it-turn-out": "How did it turn out?",
diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue
index 77ae6490ace..5d3befd2fae 100644
--- a/frontend/layouts/default.vue
+++ b/frontend/layouts/default.vue
@@ -169,6 +169,12 @@ export default defineComponent({
to: "/shopping-lists",
restricted: true,
},
+ {
+ icon: $globals.icons.timelineText,
+ title: i18n.t("recipe.timeline"),
+ to: "/group/timeline",
+ restricted: true,
+ },
{
icon: $globals.icons.tags,
to: "/recipes/categories",
diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts
index d5e8e61b181..e99b691d767 100644
--- a/frontend/lib/api/types/recipe.ts
+++ b/frontend/lib/api/types/recipe.ts
@@ -355,6 +355,7 @@ export interface RecipeTimelineEventIn {
eventMessage?: string;
image?: string;
timestamp?: string;
+ recipeId: string;
}
export interface RecipeTimelineEventOut {
userId: string;
diff --git a/frontend/lib/api/user/recipes/recipe.ts b/frontend/lib/api/user/recipes/recipe.ts
index e397ec12dd4..5499fd98b65 100644
--- a/frontend/lib/api/user/recipes/recipe.ts
+++ b/frontend/lib/api/user/recipes/recipe.ts
@@ -39,6 +39,7 @@ const routes = {
recipesParseIngredient: `${prefix}/parser/ingredient`,
recipesParseIngredients: `${prefix}/parser/ingredients`,
recipesCreateFromOcr: `${prefix}/recipes/create-ocr`,
+ recipesTimelineEvent: `${prefix}/recipes/timeline/events`,
recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`,
recipesRecipeSlugExport: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports`,
@@ -50,9 +51,7 @@ const routes = {
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
recipesSlugLastMade: (slug: string) => `${prefix}/recipes/${slug}/last-made`,
-
- recipesSlugTimelineEvent: (slug: string) => `${prefix}/recipes/${slug}/timeline/events`,
- recipesSlugTimelineEventId: (slug: string, id: string) => `${prefix}/recipes/${slug}/timeline/events/${id}`,
+ recipesTimelineEventId: (id: string) => `${prefix}/recipes/timeline/events/${id}`,
};
export type RecipeSearchQuery = {
@@ -170,24 +169,24 @@ export class RecipeAPI extends BaseCRUDAPI {
return await this.requests.patch(routes.recipesSlugLastMade(recipeSlug), { timestamp })
}
- async createTimelineEvent(recipeSlug: string, payload: RecipeTimelineEventIn) {
- return await this.requests.post(routes.recipesSlugTimelineEvent(recipeSlug), payload);
+ async createTimelineEvent(payload: RecipeTimelineEventIn) {
+ return await this.requests.post(routes.recipesTimelineEvent, payload);
}
- async updateTimelineEvent(recipeSlug: string, eventId: string, payload: RecipeTimelineEventUpdate) {
+ async updateTimelineEvent(eventId: string, payload: RecipeTimelineEventUpdate) {
return await this.requests.put(
- routes.recipesSlugTimelineEventId(recipeSlug, eventId),
+ routes.recipesTimelineEventId(eventId),
payload
);
}
- async deleteTimelineEvent(recipeSlug: string, eventId: string) {
- return await this.requests.delete(routes.recipesSlugTimelineEventId(recipeSlug, eventId));
+ async deleteTimelineEvent(eventId: string) {
+ return await this.requests.delete(routes.recipesTimelineEventId(eventId));
}
- async getAllTimelineEvents(recipeSlug: string, page = 1, perPage = -1, params = {} as any) {
+ async getAllTimelineEvents(page = 1, perPage = -1, params = {} as any) {
return await this.requests.get>(
- routes.recipesSlugTimelineEvent(recipeSlug),
+ routes.recipesTimelineEvent,
{
params: { page, perPage, ...params },
}
diff --git a/frontend/pages/group/timeline.vue b/frontend/pages/group/timeline.vue
new file mode 100644
index 00000000000..a8c14d5094d
--- /dev/null
+++ b/frontend/pages/group/timeline.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+ {{ $t("recipe.group-global-timeline", { groupName }) }}
+
+
+
+
+
+
diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py
index a7288856302..ce3538b7f54 100644
--- a/mealie/routes/recipe/recipe_crud_routes.py
+++ b/mealie/routes/recipe/recipe_crud_routes.py
@@ -4,7 +4,7 @@
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.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 @@ def get_all(
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:
diff --git a/mealie/routes/recipe/timeline_events.py b/mealie/routes/recipe/timeline_events.py
index 445112716c8..c6d820ac6b1 100644
--- a/mealie/routes/recipe/timeline_events.py
+++ b/mealie/routes/recipe/timeline_events.py
@@ -6,7 +6,6 @@
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.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 @@ def mixins(self):
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 @@ def create_one(self, slug: str, data: RecipeTimelineEventIn):
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
diff --git a/mealie/schema/recipe/recipe_timeline_events.py b/mealie/schema/recipe/recipe_timeline_events.py
index f82466720a7..8789432dc3d 100644
--- a/mealie/schema/recipe/recipe_timeline_events.py
+++ b/mealie/schema/recipe/recipe_timeline_events.py
@@ -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 Config:
class RecipeTimelineEventCreate(RecipeTimelineEventIn):
- recipe_id: UUID4
user_id: UUID4
diff --git a/mealie/schema/response/query_filter.py b/mealie/schema/response/query_filter.py
index e62e74ae64f..0f4704fe24a 100644
--- a/mealie/schema/response/query_filter.py
+++ b/mealie/schema/response/query_filter.py
@@ -4,14 +4,18 @@
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 @@ def filter_query(self, query: Select, model: type[Model]) -> Select:
# 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")
+
+ 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}"
- if not hasattr(model, component.attribute_name):
- raise ValueError(f"invalid query string: '{component.attribute_name}' does not exist on this schema")
+ 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
diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py
index 08df0c06cfa..53138b73be8 100644
--- a/mealie/services/recipe/recipe_service.py
+++ b/mealie/services/recipe/recipe_service.py
@@ -3,7 +3,7 @@
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 @@ def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB):
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 @@ def _recipe_creation_factory(user: PrivateUser, name: str, additional_attrs: dic
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"
diff --git a/mealie/services/scheduler/tasks/create_timeline_events.py b/mealie/services/scheduler/tasks/create_timeline_events.py
index c5e62375630..2d7d7213544 100644
--- a/mealie/services/scheduler/tasks/create_timeline_events.py
+++ b/mealie/services/scheduler/tasks/create_timeline_events.py
@@ -6,10 +6,7 @@
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
diff --git a/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py b/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py
index 6c5306c660e..94332149b6d 100644
--- a/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py
+++ b/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py
@@ -65,9 +65,11 @@ def test_recipe_migration(api_client: TestClient, unique_user: TestUser, mig: Mi
response = api_client.get(api_routes.recipes, params=params, headers=unique_user.token)
query_data = assert_derserialize(response)
assert len(query_data["items"])
- slug = query_data["items"][0]["slug"]
- response = api_client.get(api_routes.recipes_slug_timeline_events(slug), headers=unique_user.token)
+ recipe_id = query_data["items"][0]["id"]
+ params = {"queryFilter": f"recipe_id={recipe_id}"}
+
+ response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
query_data = assert_derserialize(response)
events = query_data["items"]
assert len(events)
diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py
index 7244d75cc60..305311ed89d 100644
--- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py
+++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py
@@ -397,3 +397,30 @@ def test_delete_recipe_same_name(api_client: TestClient, unique_user: utils.Test
response = api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token)
response = api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token)
assert response.status_code == 404
+
+
+def test_get_recipe_by_slug_or_id(api_client: TestClient, unique_user: utils.TestUser):
+ slugs = [random_string(10) for _ in range(3)]
+
+ # Create recipes
+ for slug in slugs:
+ response = api_client.post(api_routes.recipes, json={"name": slug}, headers=unique_user.token)
+ assert response.status_code == 201
+ assert json.loads(response.text) == slug
+
+ # Get recipes by slug
+ recipe_ids = []
+ for slug in slugs:
+ response = api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token)
+ assert response.status_code == 200
+ recipe_data = response.json()
+ assert recipe_data["slug"] == slug
+ recipe_ids.append(recipe_data["id"])
+
+ # Get recipes by id
+ for recipe_id, slug in zip(recipe_ids, slugs, strict=True):
+ response = api_client.get(api_routes.recipes_slug(recipe_id), headers=unique_user.token)
+ assert response.status_code == 200
+ recipe_data = response.json()
+ assert recipe_data["slug"] == slug
+ assert recipe_data["id"] == recipe_id
diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py b/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py
index 97832b1ec19..a50d7131f58 100644
--- a/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py
+++ b/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py
@@ -1,3 +1,5 @@
+from uuid import uuid4
+
import pytest
from fastapi.testclient import TestClient
@@ -31,6 +33,7 @@ def recipes(api_client: TestClient, unique_user: TestUser):
def test_create_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
recipe = recipes[0]
new_event = {
+ "recipe_id": str(recipe.id),
"user_id": unique_user.user_id,
"subject": random_string(),
"event_type": "info",
@@ -38,7 +41,7 @@ def test_create_timeline_event(api_client: TestClient, unique_user: TestUser, re
}
event_response = api_client.post(
- api_routes.recipes_slug_timeline_events(recipe.slug),
+ api_routes.recipes_timeline_events,
json=new_event,
headers=unique_user.token,
)
@@ -54,6 +57,7 @@ def test_get_all_timeline_events(api_client: TestClient, unique_user: TestUser,
recipe = recipes[0]
events_data = [
{
+ "recipe_id": str(recipe.id),
"user_id": unique_user.user_id,
"subject": random_string(),
"event_type": "info",
@@ -64,17 +68,16 @@ def test_get_all_timeline_events(api_client: TestClient, unique_user: TestUser,
events: list[RecipeTimelineEventOut] = []
for event_data in events_data:
+ params: dict = {"queryFilter": f"recipe_id={event_data['recipe_id']}"}
event_response = api_client.post(
- api_routes.recipes_slug_timeline_events(recipe.slug), json=event_data, headers=unique_user.token
+ api_routes.recipes_timeline_events, params=params, json=event_data, headers=unique_user.token
)
events.append(RecipeTimelineEventOut.parse_obj(event_response.json()))
# check that we see them all
params = {"page": 1, "perPage": -1}
- events_response = api_client.get(
- api_routes.recipes_slug_timeline_events(recipe.slug), params=params, headers=unique_user.token
- )
+ events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
events_pagination = RecipeTimelineEventPagination.parse_obj(events_response.json())
event_ids = [event.id for event in events]
@@ -89,6 +92,7 @@ def test_get_timeline_event(api_client: TestClient, unique_user: TestUser, recip
# create an event
recipe = recipes[0]
new_event_data = {
+ "recipe_id": str(recipe.id),
"user_id": unique_user.user_id,
"subject": random_string(),
"event_type": "info",
@@ -96,16 +100,14 @@ def test_get_timeline_event(api_client: TestClient, unique_user: TestUser, recip
}
event_response = api_client.post(
- api_routes.recipes_slug_timeline_events(recipe.slug),
+ api_routes.recipes_timeline_events,
json=new_event_data,
headers=unique_user.token,
)
new_event = RecipeTimelineEventOut.parse_obj(event_response.json())
# fetch the new event
- event_response = api_client.get(
- api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id), headers=unique_user.token
- )
+ event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token)
assert event_response.status_code == 200
event = RecipeTimelineEventOut.parse_obj(event_response.json())
@@ -119,14 +121,13 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re
# create an event
recipe = recipes[0]
new_event_data = {
+ "recipe_id": str(recipe.id),
"user_id": unique_user.user_id,
"subject": old_subject,
"event_type": "info",
}
- event_response = api_client.post(
- api_routes.recipes_slug_timeline_events(recipe.slug), json=new_event_data, headers=unique_user.token
- )
+ event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
new_event = RecipeTimelineEventOut.parse_obj(event_response.json())
assert new_event.subject == old_subject
@@ -134,7 +135,7 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re
updated_event_data = {"subject": new_subject}
event_response = api_client.put(
- api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id),
+ api_routes.recipes_timeline_events_item_id(new_event.id),
json=updated_event_data,
headers=unique_user.token,
)
@@ -149,20 +150,19 @@ def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, re
# create an event
recipe = recipes[0]
new_event_data = {
+ "recipe_id": str(recipe.id),
"user_id": unique_user.user_id,
"subject": random_string(),
"event_type": "info",
"message": random_string(),
}
- event_response = api_client.post(
- api_routes.recipes_slug_timeline_events(recipe.slug), json=new_event_data, headers=unique_user.token
- )
+ event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
new_event = RecipeTimelineEventOut.parse_obj(event_response.json())
# delete the event
event_response = api_client.delete(
- api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id), headers=unique_user.token
+ api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token
)
assert event_response.status_code == 200
@@ -171,7 +171,7 @@ def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, re
# try to get the event
event_response = api_client.get(
- api_routes.recipes_slug_timeline_events_item_id(recipe.slug, deleted_event.id), headers=unique_user.token
+ api_routes.recipes_timeline_events_item_id(deleted_event.id), headers=unique_user.token
)
assert event_response.status_code == 404
@@ -180,6 +180,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
# create an event using aliases
recipe = recipes[0]
new_event_data = {
+ "recipeId": str(recipe.id),
"userId": unique_user.user_id,
"subject": random_string(),
"eventType": "info",
@@ -187,7 +188,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
}
event_response = api_client.post(
- api_routes.recipes_slug_timeline_events(recipe.slug),
+ api_routes.recipes_timeline_events,
json=new_event_data,
headers=unique_user.token,
)
@@ -197,9 +198,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
assert new_event.message == new_event_data["eventMessage"]
# fetch the new event
- event_response = api_client.get(
- api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id), headers=unique_user.token
- )
+ event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token)
assert event_response.status_code == 200
event = RecipeTimelineEventOut.parse_obj(event_response.json())
@@ -211,7 +210,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
updated_event_data = {"subject": new_subject, "eventMessage": new_message}
event_response = api_client.put(
- api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id),
+ api_routes.recipes_timeline_events_item_id(new_event.id),
json=updated_event_data,
headers=unique_user.token,
)
@@ -225,71 +224,20 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
def test_create_recipe_with_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
# make sure when the recipes fixture was created that all recipes have at least one event
for recipe in recipes:
- events_response = api_client.get(
- api_routes.recipes_slug_timeline_events(recipe.slug), headers=unique_user.token
- )
+ params = {"queryFilter": f"recipe_id={recipe.id}"}
+ events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
events_pagination = RecipeTimelineEventPagination.parse_obj(events_response.json())
assert events_pagination.items
-def test_invalid_recipe_slug(api_client: TestClient, unique_user: TestUser):
+def test_invalid_recipe_id(api_client: TestClient, unique_user: TestUser):
new_event_data = {
+ "recipe_id": str(uuid4()),
"user_id": unique_user.user_id,
"subject": random_string(),
"event_type": "info",
"message": random_string(),
}
- event_response = api_client.post(
- api_routes.recipes_slug_timeline_events(random_string()), json=new_event_data, headers=unique_user.token
- )
+ event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
assert event_response.status_code == 404
-
-
-def test_recipe_slug_mismatch(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
- # get new recipes
- recipe = recipes[0]
- invalid_recipe = recipes[1]
-
- # create a new event
- new_event_data = {
- "user_id": unique_user.user_id,
- "subject": random_string(),
- "event_type": "info",
- "message": random_string(),
- }
-
- event_response = api_client.post(
- api_routes.recipes_slug_timeline_events(recipe.slug), json=new_event_data, headers=unique_user.token
- )
- event = RecipeTimelineEventOut.parse_obj(event_response.json())
-
- # try to perform operations on the event using the wrong recipe
- event_response = api_client.get(
- api_routes.recipes_slug_timeline_events_item_id(invalid_recipe.slug, event.id),
- headers=unique_user.token,
- )
- assert event_response.status_code == 404
-
- event_response = api_client.put(
- api_routes.recipes_slug_timeline_events_item_id(invalid_recipe.slug, event.id),
- json=new_event_data,
- headers=unique_user.token,
- )
- assert event_response.status_code == 404
-
- event_response = api_client.delete(
- api_routes.recipes_slug_timeline_events_item_id(invalid_recipe.slug, event.id),
- headers=unique_user.token,
- )
- assert event_response.status_code == 404
-
- # make sure the event still exists and is unmodified
- event_response = api_client.get(
- api_routes.recipes_slug_timeline_events_item_id(recipe.slug, event.id),
- headers=unique_user.token,
- )
- assert event_response.status_code == 200
-
- existing_event = RecipeTimelineEventOut.parse_obj(event_response.json())
- assert existing_event == event
diff --git a/tests/unit_tests/repository_tests/test_pagination.py b/tests/unit_tests/repository_tests/test_pagination.py
index f1c4adf0cc0..322b1bf8837 100644
--- a/tests/unit_tests/repository_tests/test_pagination.py
+++ b/tests/unit_tests/repository_tests/test_pagination.py
@@ -1,4 +1,5 @@
import time
+from collections import defaultdict
from random import randint
from urllib.parse import parse_qsl, urlsplit
@@ -11,13 +12,16 @@
from mealie.schema.recipe.recipe_ingredient import IngredientUnit, SaveIngredientUnit
from mealie.schema.response.pagination import PaginationQuery
from mealie.services.seeder.seeder_service import SeederService
+from tests.utils import api_routes
+from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
def test_repository_pagination(database: AllRepositories, unique_user: TestUser):
group = database.groups.get_one(unique_user.group_id)
+ assert group
- seeder = SeederService(database, None, group)
+ seeder = SeederService(database, None, group) # type: ignore
seeder.seed_foods("en-US")
foods_repo = database.ingredient_foods.by_group(unique_user.group_id) # type: ignore
@@ -50,8 +54,9 @@ def test_repository_pagination(database: AllRepositories, unique_user: TestUser)
def test_pagination_response_and_metadata(database: AllRepositories, unique_user: TestUser):
group = database.groups.get_one(unique_user.group_id)
+ assert group
- seeder = SeederService(database, None, group)
+ seeder = SeederService(database, None, group) # type: ignore
seeder.seed_foods("en-US")
foods_repo = database.ingredient_foods.by_group(unique_user.group_id) # type: ignore
@@ -78,8 +83,9 @@ def test_pagination_response_and_metadata(database: AllRepositories, unique_user
def test_pagination_guides(database: AllRepositories, unique_user: TestUser):
group = database.groups.get_one(unique_user.group_id)
+ assert group
- seeder = SeederService(database, None, group)
+ seeder = SeederService(database, None, group) # type: ignore
seeder.seed_foods("en-US")
foods_repo = database.ingredient_foods.by_group(unique_user.group_id) # type: ignore
@@ -107,10 +113,10 @@ def test_pagination_guides(database: AllRepositories, unique_user: TestUser):
random_page_of_results = foods_repo.page_all(query)
random_page_of_results.set_pagination_guides(foods_route, query.dict())
- next_params = dict(parse_qsl(urlsplit(random_page_of_results.next).query))
+ next_params: dict = dict(parse_qsl(urlsplit(random_page_of_results.next).query)) # type: ignore
assert int(next_params["page"]) == random_page + 1
- prev_params = dict(parse_qsl(urlsplit(random_page_of_results.previous).query))
+ prev_params: dict = dict(parse_qsl(urlsplit(random_page_of_results.previous).query)) # type: ignore
assert int(prev_params["page"]) == random_page - 1
source_params = camelize(query.dict())
@@ -173,7 +179,7 @@ def test_pagination_filter_datetimes(
unit_1 = query_units[1]
unit_2 = query_units[2]
- dt = unit_2.created_at.isoformat()
+ dt = unit_2.created_at.isoformat() # type: ignore
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>="{dt}"')
unit_results = units_repo.page_all(query).items
assert len(unit_results) == 2
@@ -194,7 +200,7 @@ def test_pagination_filter_advanced(query_units: tuple[RepositoryUnit, Ingredien
units_repo = query_units[0]
unit_3 = query_units[3]
- dt = unit_3.created_at.isoformat()
+ dt = str(unit_3.created_at.isoformat()) # type: ignore
qf = f'name="test unit 1" OR (useAbbreviation=f AND (name="test unit 2" OR createdAt > "{dt}"))'
query = PaginationQuery(page=1, per_page=-1, query_filter=qf)
unit_results = units_repo.page_all(query).items
@@ -206,8 +212,11 @@ def test_pagination_filter_advanced(query_units: tuple[RepositoryUnit, Ingredien
"qf",
[
pytest.param('(name="test name" AND useAbbreviation=f))', id="unbalanced parenthesis"),
+ pytest.param('id="this is not a valid UUID"', id="invalid UUID"),
pytest.param('createdAt="this is not a valid datetime format"', id="invalid datetime format"),
pytest.param('badAttribute="test value"', id="invalid attribute"),
+ pytest.param('group.badAttribute="test value"', id="bad nested attribute"),
+ pytest.param('group.preferences.badAttribute="test value"', id="bad double nested attribute"),
],
)
def test_malformed_query_filters(api_client: TestClient, unique_user: TestUser, qf: str):
@@ -216,3 +225,46 @@ def test_malformed_query_filters(api_client: TestClient, unique_user: TestUser,
response = api_client.get(route, params={"queryFilter": qf}, headers=unique_user.token)
assert response.status_code == 400
+
+
+def test_pagination_filter_nested(api_client: TestClient, user_tuple: list[TestUser]):
+ # create a few recipes for each user
+ slugs: defaultdict[int, list[str]] = defaultdict(list)
+ for i, user in enumerate(user_tuple):
+ for _ in range(random_int(3, 5)):
+ slug: str = random_string()
+ response = api_client.post(api_routes.recipes, json={"name": slug}, headers=user.token)
+
+ assert response.status_code == 201
+ slugs[i].append(slug)
+
+ # query recipes with a nested user filter
+ recipe_ids: defaultdict[int, list[str]] = defaultdict(list)
+ for i, user in enumerate(user_tuple):
+ params = {"page": 1, "perPage": -1, "queryFilter": f'user.id="{user.user_id}"'}
+ response = api_client.get(api_routes.recipes, params=params, headers=user.token)
+
+ assert response.status_code == 200
+ recipes_data: list[dict] = response.json()["items"]
+ assert recipes_data
+
+ for recipe_data in recipes_data:
+ slug = recipe_data["slug"]
+ assert slug in slugs[i]
+ assert slug not in slugs[(i + 1) % len(user_tuple)]
+
+ recipe_ids[i].append(recipe_data["id"])
+
+ # query timeline events with a double nested recipe.user filter
+ for i, user in enumerate(user_tuple):
+ params = {"page": 1, "perPage": -1, "queryFilter": f'recipe.user.id="{user.user_id}"'}
+ response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=user.token)
+
+ assert response.status_code == 200
+ events_data: list[dict] = response.json()["items"]
+ assert events_data
+
+ for event_data in events_data:
+ recipe_id = event_data["recipeId"]
+ assert recipe_id in recipe_ids[i]
+ assert recipe_id not in recipe_ids[(i + 1) % len(user_tuple)]
diff --git a/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py b/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py
index 08bf47b989a..f87b2af4d5a 100644
--- a/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py
+++ b/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py
@@ -5,9 +5,7 @@
from mealie.schema.meal_plan.new_meal import CreatePlanEntry
from mealie.schema.recipe.recipe import RecipeSummary
-from mealie.services.scheduler.tasks.create_timeline_events import (
- create_mealplan_timeline_events,
-)
+from mealie.services.scheduler.tasks.create_timeline_events import create_mealplan_timeline_events
from tests import utils
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
@@ -31,7 +29,8 @@ def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser):
assert recipe.last_made is None
# store the number of events, so we can compare later
- response = api_client.get(api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token)
+ params = {"queryFilter": f"recipe_id={recipe_id}"}
+ response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
response_json = response.json()
initial_event_count = len(response_json["items"])
@@ -45,10 +44,14 @@ def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser):
# run the task and check to make sure a new event was created from the mealplan
create_mealplan_timeline_events()
- params = {"page": "1", "perPage": "-1", "orderBy": "created_at", "orderDirection": "desc"}
- response = api_client.get(
- api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token, params=params
- )
+ params = {
+ "page": "1",
+ "perPage": "-1",
+ "orderBy": "created_at",
+ "orderDirection": "desc",
+ "queryFilter": f"recipe_id={recipe_id}",
+ }
+ response = api_client.get(api_routes.recipes_timeline_events, headers=unique_user.token, params=params)
response_json = response.json()
assert len(response_json["items"]) == initial_event_count + 1
@@ -91,7 +94,8 @@ def test_new_mealplan_event_duplicates(api_client: TestClient, unique_user: Test
recipe_id = recipe.id
# store the number of events, so we can compare later
- response = api_client.get(api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token)
+ params = {"queryFilter": f"recipe_id={recipe_id}"}
+ response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
response_json = response.json()
initial_event_count = len(response_json["items"])
@@ -106,10 +110,14 @@ def test_new_mealplan_event_duplicates(api_client: TestClient, unique_user: Test
for _ in range(3):
create_mealplan_timeline_events()
- params = {"page": "1", "perPage": "-1", "orderBy": "created_at", "orderDirection": "desc"}
- response = api_client.get(
- api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token, params=params
- )
+ params = {
+ "page": "1",
+ "perPage": "-1",
+ "orderBy": "created_at",
+ "orderDirection": "desc",
+ "queryFilter": f"recipe_id={recipe_id}",
+ }
+ response = api_client.get(api_routes.recipes_timeline_events, headers=unique_user.token, params=params)
response_json = response.json()
assert len(response_json["items"]) == initial_event_count + 1
@@ -125,7 +133,8 @@ def test_new_mealplan_events_with_multiple_recipes(api_client: TestClient, uniqu
recipes.append(RecipeSummary.parse_obj(response.json()))
# store the number of events, so we can compare later
- response = api_client.get(api_routes.recipes_slug_timeline_events(str(recipes[0].slug)), headers=unique_user.token)
+ params = {"queryFilter": f"recipe_id={recipes[0].id}"}
+ response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
response_json = response.json()
initial_event_count = len(response_json["items"])
@@ -149,10 +158,14 @@ def test_new_mealplan_events_with_multiple_recipes(api_client: TestClient, uniqu
for recipe in recipes:
target_count = initial_event_count + mealplan_count_by_recipe_id[recipe.id] # type: ignore
- params = {"page": "1", "perPage": "-1", "orderBy": "created_at", "orderDirection": "desc"}
- response = api_client.get(
- api_routes.recipes_slug_timeline_events(recipe.slug), headers=unique_user.token, params=params
- )
+ params = {
+ "page": "1",
+ "perPage": "-1",
+ "orderBy": "created_at",
+ "orderDirection": "desc",
+ "queryFilter": f"recipe_id={recipe.id}",
+ }
+ response = api_client.get(api_routes.recipes_timeline_events, headers=unique_user.token, params=params)
response_json = response.json()
assert len(response_json["items"]) == target_count
@@ -167,10 +180,9 @@ def test_new_mealplan_events_with_multiple_recipes(api_client: TestClient, uniqu
"perPage": "-1",
"orderBy": "created_at",
"orderDirection": "desc",
+ "queryFilter": f"recipe_id={recipe.id}",
}
- response = api_client.get(
- api_routes.recipes_slug_timeline_events(recipe.slug), headers=unique_user.token, params=params
- )
+ response = api_client.get(api_routes.recipes_timeline_events, headers=unique_user.token, params=params)
response_json = response.json()
assert len(response_json["items"]) == target_count
diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py
index 44fef50ba26..fbc46c9b619 100644
--- a/tests/utils/api_routes/__init__.py
+++ b/tests/utils/api_routes/__init__.py
@@ -39,6 +39,8 @@
"""`/api/admin/server-tasks`"""
admin_users = "/api/admin/users"
"""`/api/admin/users`"""
+admin_users_password_reset_token = "/api/admin/users/password-reset-token"
+"""`/api/admin/users/password-reset-token`"""
admin_users_unlock = "/api/admin/users/unlock"
"""`/api/admin/users/unlock`"""
app_about = "/api/app/about"
@@ -159,6 +161,8 @@
"""`/api/recipes/summary/untagged`"""
recipes_test_scrape_url = "/api/recipes/test-scrape-url"
"""`/api/recipes/test-scrape-url`"""
+recipes_timeline_events = "/api/recipes/timeline/events"
+"""`/api/recipes/timeline/events`"""
shared_recipes = "/api/shared/recipes"
"""`/api/shared/recipes`"""
units = "/api/units"
@@ -386,14 +390,9 @@ def recipes_slug_last_made(slug):
return f"{prefix}/recipes/{slug}/last-made"
-def recipes_slug_timeline_events(slug):
- """`/api/recipes/{slug}/timeline/events`"""
- return f"{prefix}/recipes/{slug}/timeline/events"
-
-
-def recipes_slug_timeline_events_item_id(slug, item_id):
- """`/api/recipes/{slug}/timeline/events/{item_id}`"""
- return f"{prefix}/recipes/{slug}/timeline/events/{item_id}"
+def recipes_timeline_events_item_id(item_id):
+ """`/api/recipes/timeline/events/{item_id}`"""
+ return f"{prefix}/recipes/timeline/events/{item_id}"
def shared_recipes_item_id(item_id):
From 75698c531aaa057e1d35ae5bd4fb09ee8a11b47b Mon Sep 17 00:00:00 2001
From: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Date: Tue, 25 Apr 2023 12:46:58 -0500
Subject: [PATCH 3/3] fix: Shopping List Label Dropdown Doesn't Save Correctly
(#2361)
* only update items by label on refresh
* made changes more responsive
* fast re-order items when labels are re-ordered
---
.../Domain/ShoppingList/ShoppingListItem.vue | 4 ++
frontend/pages/shopping-lists/_id.vue | 39 ++++++++++++-------
2 files changed, 29 insertions(+), 14 deletions(-)
diff --git a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue
index 93b2604ed44..0cb6e8dd3c6 100644
--- a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue
+++ b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue
@@ -138,6 +138,10 @@ export default defineComponent({
});
const edit = ref(false);
function toggleEdit(val = !edit.value) {
+ if (edit.value === val) {
+ return;
+ }
+
if (val) {
// update local copy of item with the current value
localListItem.value = props.value;
diff --git a/frontend/pages/shopping-lists/_id.vue b/frontend/pages/shopping-lists/_id.vue
index 9b87fd006dc..f80f1ff1a9d 100644
--- a/frontend/pages/shopping-lists/_id.vue
+++ b/frontend/pages/shopping-lists/_id.vue
@@ -32,7 +32,7 @@
-
+
{{ $globals.icons.tags }}
@@ -206,14 +206,13 @@