diff --git a/docs/docs/documentation/getting-started/api-usage.md b/docs/docs/documentation/getting-started/api-usage.md index 734fe256799b..0930d33bcafb 100644 --- a/docs/docs/documentation/getting-started/api-usage.md +++ b/docs/docs/documentation/getting-started/api-usage.md @@ -72,6 +72,13 @@ This filter will find all recipes created on or after a particular date:
This filter will find all units that have `useAbbreviation` disabled:
`useAbbreviation = false` +##### Nested Property filters +When querying tables with relationships, you can filter properties on related tables. For instance, if you want to query all recipes owned by a particular user:
+`user.username = "SousChef20220320"` + +This timeline event filter will return all timeline events for recipes that were created after a particular date:
+`recipe.createdAt >= "2023-02-25"` + ##### Compound Filters You can combine multiple filter statements using logical operators (`AND`, `OR`). diff --git a/frontend/components/Domain/Recipe/RecipeCardMobile.vue b/frontend/components/Domain/Recipe/RecipeCardMobile.vue index c38b65f4bfaa..c729b874c87b 100644 --- a/frontend/components/Domain/Recipe/RecipeCardMobile.vue +++ b/frontend/components/Domain/Recipe/RecipeCardMobile.vue @@ -7,8 +7,18 @@ :to="$listeners.selected ? undefined : `/recipe/${slug}`" @click="$emit('selected')" > + + + - + + /> @@ -25,7 +35,7 @@ -
+
- - - - - - - - - - - - - - - {{ $globals.icons.calendar }} - {{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }} - - - - {{ event.subject }} - - - - - - - - - - - {{ event.subject }} -
- {{ event.eventMessage }} -
-
-
-
-
-
-
-
-
- - - {{ $t("recipe.timeline-is-empty") }} - - -
- - - diff --git a/frontend/components/Domain/Recipe/RecipeLastMade.vue b/frontend/components/Domain/Recipe/RecipeLastMade.vue index a30912d8c19d..5e0863552519 100644 --- a/frontend/components/Domain/Recipe/RecipeLastMade.vue +++ b/frontend/components/Domain/Recipe/RecipeLastMade.vue @@ -75,7 +75,7 @@ import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/comp import { whenever } from "@vueuse/core"; import { VForm } from "~/types/vuetify"; import { useUserApi } from "~/composables/api"; -import { RecipeTimelineEventIn } from "~/lib/api/types/recipe"; +import { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe"; export default defineComponent({ props: { @@ -83,9 +83,9 @@ export default defineComponent({ type: String, default: null, }, - recipeSlug: { - type: String, - required: true, + recipe: { + type: Object as () => Recipe, + default: null, }, }, setup(props, context) { @@ -99,6 +99,7 @@ export default defineComponent({ eventType: "comment", eventMessage: "", timestamp: undefined, + recipeId: props.recipe?.id || "", }); whenever( @@ -113,20 +114,21 @@ export default defineComponent({ const state = reactive({datePickerMenu: false}); async function createTimelineEvent() { - if (!newTimelineEvent.value.timestamp) { + if (!(newTimelineEvent.value.timestamp && props.recipe?.id && props.recipe?.slug)) { return; } + newTimelineEvent.value.recipeId = props.recipe.id const actions: Promise[] = []; // the user only selects the date, so we set the time to end of day local time // we choose the end of day so it always comes after "new recipe" events newTimelineEvent.value.timestamp = new Date(newTimelineEvent.value.timestamp + "T23:59:59").toISOString(); - actions.push(userApi.recipes.createTimelineEvent(props.recipeSlug, newTimelineEvent.value)); + actions.push(userApi.recipes.createTimelineEvent(newTimelineEvent.value)); // we also update the recipe's last made value if (!props.value || newTimelineEvent.value.timestamp > props.value) { - actions.push(userApi.recipes.updateLastMade(props.recipeSlug, newTimelineEvent.value.timestamp)); + actions.push(userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp)); // update recipe in parent so the user can see it // we remove the trailing "Z" since this is how the API returns it diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue index c433230e3c88..0754a13b3a1d 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue @@ -13,7 +13,7 @@
diff --git a/frontend/components/Domain/Recipe/RecipeTimeline.vue b/frontend/components/Domain/Recipe/RecipeTimeline.vue new file mode 100644 index 000000000000..bcd854c6ab14 --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeTimeline.vue @@ -0,0 +1,266 @@ + + + diff --git a/frontend/components/Domain/Recipe/RecipeTimelineBadge.vue b/frontend/components/Domain/Recipe/RecipeTimelineBadge.vue index d699f0ff396d..9fd03d0eb835 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 cbd22810550c..501c44819f3e 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 000000000000..f5674607cfdb --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeTimelineItem.vue @@ -0,0 +1,162 @@ + + + diff --git a/frontend/components/global/AppLoader.vue b/frontend/components/global/AppLoader.vue index 34913a249b6b..c7ec8e7bc278 100644 --- a/frontend/components/global/AppLoader.vue +++ b/frontend/components/global/AppLoader.vue @@ -1,21 +1,23 @@ @@ -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 471e2482e475..c93c5dcb4c01 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 5e240f8257cf..569d95d7edb8 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 77ae6490ace8..5d3befd2faec 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 d5e8e61b1817..e99b691d767d 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 e397ec12dd4a..5499fd98b655 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 000000000000..a8c14d5094d5 --- /dev/null +++ b/frontend/pages/group/timeline.vue @@ -0,0 +1,52 @@ + + + diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index a72888563027..ce3538b7f540 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -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: diff --git a/mealie/routes/recipe/timeline_events.py b/mealie/routes/recipe/timeline_events.py index 445112716c82..c6d820ac6b15 100644 --- a/mealie/routes/recipe/timeline_events.py +++ b/mealie/routes/recipe/timeline_events.py @@ -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 diff --git a/mealie/schema/recipe/recipe_timeline_events.py b/mealie/schema/recipe/recipe_timeline_events.py index f82466720a78..8789432dc3d6 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 RecipeTimelineEventIn(MealieModel): 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 e62e74ae64fd..0f4704fe24a0 100644 --- a/mealie/schema/response/query_filter.py +++ b/mealie/schema/response/query_filter.py @@ -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 diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 08df0c06cfad..53138b73be84 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -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" diff --git a/mealie/services/scheduler/tasks/create_timeline_events.py b/mealie/services/scheduler/tasks/create_timeline_events.py index c5e62375630b..2d7d72135448 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.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 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 6c5306c660e2..94332149b6db 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 7244d75cc60b..305311ed89db 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 97832b1ec199..a50d7131f58b 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 f1c4adf0cc0d..322b1bf88379 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.repos.repository_units import RepositoryUnit 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 08bf47b989a6..f87b2af4d5a1 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 pydantic import UUID4 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 44fef50ba26a..fbc46c9b6195 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -39,6 +39,8 @@ admin_server_tasks = "/api/admin/server-tasks" """`/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 @@ recipes_summary_untagged = "/api/recipes/summary/untagged" """`/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):