From 6ee64535dfcdec6797d971206e3188a0dac2a4b9 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 1 Nov 2022 03:12:26 -0500 Subject: [PATCH] feat: recipe timeline backend api (#1685) * added recipe_timeline_events table to db * added schema and routes for recipe timeline events * added missing mixin and fixed update schema * added tests * adjusted migration revision tree * updated alembic revision test * added initial timeline event for new recipes * added additional tests * added event bus support * renamed event_dt to timestamp * add timeline_events to ignore list * run code-gen * use new test routes implementation * use doc string syntax * moved event type enum from db to schema Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com> --- ...0ed_add_is_ocr_recipe_column_to_recipes.py | 2 +- ...dd_extras_to_shopping_lists_list_items_.py | 2 +- ...07915c_add_recipe_timeline_events_table.py | 50 ++++ .../use-locales/available-locales.ts | 2 +- frontend/lib/api/types/recipe.ts | 35 +++ frontend/types/components.d.ts | 2 + mealie/db/models/recipe/recipe.py | 6 + mealie/db/models/recipe/recipe_timeline.py | 38 +++ mealie/db/models/users/users.py | 1 + mealie/repos/repository_factory.py | 6 + mealie/routes/recipe/__init__.py | 3 +- mealie/routes/recipe/timeline_events.py | 146 ++++++++++ mealie/schema/recipe/__init__.py | 12 + .../schema/recipe/recipe_timeline_events.py | 53 ++++ .../services/event_bus_service/event_types.py | 7 + mealie/services/recipe/recipe_service.py | 16 +- .../test_recipe_timeline_events.py | 252 ++++++++++++++++++ .../backup_v2_tests/test_alchemy_exporter.py | 2 +- tests/utils/api_routes/__init__.py | 10 + 19 files changed, 639 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/2022-09-27-14.53.14_2ea7a807915c_add_recipe_timeline_events_table.py create mode 100644 mealie/db/models/recipe/recipe_timeline.py create mode 100644 mealie/routes/recipe/timeline_events.py create mode 100644 mealie/schema/recipe/recipe_timeline_events.py create mode 100644 tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py diff --git a/alembic/versions/2022-08-05-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py b/alembic/versions/2022-08-05-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py index 0f5af03429c0..51da2f319d5f 100644 --- a/alembic/versions/2022-08-05-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py +++ b/alembic/versions/2022-08-05-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py @@ -1,7 +1,7 @@ """Add is_ocr_recipe column to recipes Revision ID: 089bfa50d0ed -Revises: f30cf048c228 +Revises: 188374910655 Create Date: 2022-08-05 17:07:07.389271 """ diff --git a/alembic/versions/2022-08-29-13.57.40_44e8d670719d_add_extras_to_shopping_lists_list_items_.py b/alembic/versions/2022-08-29-13.57.40_44e8d670719d_add_extras_to_shopping_lists_list_items_.py index 10a4187508a9..8a5477a8d858 100644 --- a/alembic/versions/2022-08-29-13.57.40_44e8d670719d_add_extras_to_shopping_lists_list_items_.py +++ b/alembic/versions/2022-08-29-13.57.40_44e8d670719d_add_extras_to_shopping_lists_list_items_.py @@ -1,7 +1,7 @@ """add extras to shopping lists, list items, and ingredient foods Revision ID: 44e8d670719d -Revises: 188374910655 +Revises: 089bfa50d0ed Create Date: 2022-08-29 13:57:40.452245 """ diff --git a/alembic/versions/2022-09-27-14.53.14_2ea7a807915c_add_recipe_timeline_events_table.py b/alembic/versions/2022-09-27-14.53.14_2ea7a807915c_add_recipe_timeline_events_table.py new file mode 100644 index 000000000000..1228bd06bf06 --- /dev/null +++ b/alembic/versions/2022-09-27-14.53.14_2ea7a807915c_add_recipe_timeline_events_table.py @@ -0,0 +1,50 @@ +"""add recipe_timeline_events table + +Revision ID: 2ea7a807915c +Revises: 44e8d670719d +Create Date: 2022-09-27 14:53:14.111054 + +""" +import sqlalchemy as sa + +import mealie.db.migration_types +from alembic import op + +# revision identifiers, used by Alembic. +revision = "2ea7a807915c" +down_revision = "44e8d670719d" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "recipe_timeline_events", + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("update_at", sa.DateTime(), nullable=True), + sa.Column("id", mealie.db.migration_types.GUID(), nullable=False), + sa.Column("recipe_id", mealie.db.migration_types.GUID(), nullable=False), + sa.Column("user_id", mealie.db.migration_types.GUID(), nullable=False), + sa.Column("subject", sa.String(), nullable=False), + sa.Column("message", sa.String(), nullable=True), + sa.Column("event_type", sa.String(), nullable=True), + sa.Column("image", sa.String(), nullable=True), + sa.Column("timestamp", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["recipe_id"], + ["recipes.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("recipe_timeline_events") + # ### end Alembic commands ### diff --git a/frontend/composables/use-locales/available-locales.ts b/frontend/composables/use-locales/available-locales.ts index 0af3404c0a58..5088c5d79184 100644 --- a/frontend/composables/use-locales/available-locales.ts +++ b/frontend/composables/use-locales/available-locales.ts @@ -73,7 +73,7 @@ export const LOCALES = [ { name: "Norsk (Norwegian)", value: "no-NO", - progress: 80, + progress: 85, }, { name: "Nederlands (Dutch)", diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts index 5618dee0f422..e421325d1a49 100644 --- a/frontend/lib/api/types/recipe.ts +++ b/frontend/lib/api/types/recipe.ts @@ -8,6 +8,7 @@ export type ExportTypes = "json"; export type RegisteredParser = "nlp" | "brute"; export type OrderDirection = "asc" | "desc"; +export type TimelineEventType = "system" | "info" | "comment"; export interface AssignCategories { recipes: string[]; @@ -340,6 +341,40 @@ export interface RecipeTagResponse { slug: string; recipes?: RecipeSummary[]; } +export interface RecipeTimelineEventCreate { + userId: string; + subject: string; + eventType: TimelineEventType; + message?: string; + image?: string; + timestamp?: string; + recipeId: string; +} +export interface RecipeTimelineEventIn { + userId?: string; + subject: string; + eventType: TimelineEventType; + message?: string; + image?: string; + timestamp?: string; +} +export interface RecipeTimelineEventOut { + userId: string; + subject: string; + eventType: TimelineEventType; + message?: string; + image?: string; + timestamp?: string; + recipeId: string; + id: string; + createdAt: string; + updateAt: string; +} +export interface RecipeTimelineEventUpdate { + subject: string; + message?: string; + image?: string; +} export interface RecipeToolCreate { name: string; onHand?: boolean; diff --git a/frontend/types/components.d.ts b/frontend/types/components.d.ts index fda59d749abc..4faffb666254 100644 --- a/frontend/types/components.d.ts +++ b/frontend/types/components.d.ts @@ -18,6 +18,7 @@ import ButtonLink from "@/components/global/ButtonLink.vue"; import ContextMenu from "@/components/global/ContextMenu.vue"; import CrudTable from "@/components/global/CrudTable.vue"; import DevDumpJson from "@/components/global/DevDumpJson.vue"; +import DropZone from "@/components/global/DropZone.vue"; import HelpIcon from "@/components/global/HelpIcon.vue"; import InputColor from "@/components/global/InputColor.vue"; import InputLabelType from "@/components/global/InputLabelType.vue"; @@ -56,6 +57,7 @@ declare module "vue" { ContextMenu: typeof ContextMenu; CrudTable: typeof CrudTable; DevDumpJson: typeof DevDumpJson; + DropZone: typeof DropZone; HelpIcon: typeof HelpIcon; InputColor: typeof InputColor; InputLabelType: typeof InputLabelType; diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index fb387f40ee4a..4f356a7d922c 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -18,6 +18,7 @@ from .ingredient import RecipeIngredient from .instruction import RecipeInstruction from .note import Note from .nutrition import Nutrition +from .recipe_timeline import RecipeTimelineEvent from .settings import RecipeSettings from .shared import RecipeShareTokenModel from .tag import recipes_to_tags @@ -82,6 +83,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): "RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan" ) + timeline_events: list[RecipeTimelineEvent] = orm.relationship( + "RecipeTimelineEvent", back_populates="recipe", cascade="all, delete, delete-orphan" + ) + # Mealie Specific settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan") tags = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes") @@ -117,6 +122,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): "recipe_instructions", "settings", "comments", + "timeline_events", } @validates("name") diff --git a/mealie/db/models/recipe/recipe_timeline.py b/mealie/db/models/recipe/recipe_timeline.py new file mode 100644 index 000000000000..71b22b7b489f --- /dev/null +++ b/mealie/db/models/recipe/recipe_timeline.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from sqlalchemy import Column, DateTime, ForeignKey, String +from sqlalchemy.orm import relationship + +from .._model_base import BaseMixins, SqlAlchemyBase +from .._model_utils import auto_init +from .._model_utils.guid import GUID + + +class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins): + __tablename__ = "recipe_timeline_events" + id = Column(GUID, primary_key=True, default=GUID.generate) + + # Parent Recipe + recipe_id = Column(GUID, ForeignKey("recipes.id"), nullable=False) + recipe = relationship("RecipeModel", back_populates="timeline_events") + + # Related User (Actor) + user_id = Column(GUID, ForeignKey("users.id"), nullable=False) + user = relationship("User", back_populates="recipe_timeline_events", single_parent=True, foreign_keys=[user_id]) + + # General Properties + subject = Column(String, nullable=False) + message = Column(String) + event_type = Column(String) + image = Column(String) + + # Timestamps + timestamp = Column(DateTime) + + @auto_init() + def __init__( + self, + timestamp=None, + **_, + ) -> None: + self.timestamp = timestamp or datetime.now() diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 71a3b43e921e..f77fcf56743e 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -52,6 +52,7 @@ class User(SqlAlchemyBase, BaseMixins): tokens = orm.relationship(LongLiveToken, **sp_args) comments = orm.relationship("RecipeComment", **sp_args) + recipe_timeline_events = orm.relationship("RecipeTimelineEvent", **sp_args) password_reset_tokens = orm.relationship("PasswordResetModel", **sp_args) owned_recipes_id = Column(GUID, ForeignKey("recipes.id")) diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py index e8a54c6bb3ee..761f0ea5c30c 100644 --- a/mealie/repos/repository_factory.py +++ b/mealie/repos/repository_factory.py @@ -21,6 +21,7 @@ from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.comment import RecipeComment from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.recipe import RecipeModel +from mealie.db.models.recipe.recipe_timeline import RecipeTimelineEvent from mealie.db.models.recipe.shared import RecipeShareTokenModel from mealie.db.models.recipe.tag import Tag from mealie.db.models.recipe.tool import Tool @@ -49,6 +50,7 @@ from mealie.schema.recipe import Recipe, RecipeCommentOut, RecipeToolOut from mealie.schema.recipe.recipe_category import CategoryOut, TagOut from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit from mealie.schema.recipe.recipe_share_token import RecipeShareToken +from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventOut from mealie.schema.reports.reports import ReportEntryOut, ReportOut from mealie.schema.server import ServerTask from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser @@ -123,6 +125,10 @@ class AllRepositories: def recipe_share_tokens(self) -> RepositoryGeneric[RecipeShareToken, RecipeShareTokenModel]: return RepositoryGeneric(self.session, PK_ID, RecipeShareTokenModel, RecipeShareToken) + @cached_property + def recipe_timeline_events(self) -> RepositoryGeneric[RecipeTimelineEventOut, RecipeTimelineEvent]: + return RepositoryGeneric(self.session, PK_ID, RecipeTimelineEvent, RecipeTimelineEventOut) + # ================================================================ # User diff --git a/mealie/routes/recipe/__init__.py b/mealie/routes/recipe/__init__.py index 2d241f95c540..b85bce9fc229 100644 --- a/mealie/routes/recipe/__init__.py +++ b/mealie/routes/recipe/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from . import all_recipe_routes, bulk_actions, comments, recipe_crud_routes, shared_routes +from . import all_recipe_routes, bulk_actions, comments, recipe_crud_routes, shared_routes, timeline_events prefix = "/recipes" @@ -12,3 +12,4 @@ router.include_router(recipe_crud_routes.router) router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"]) router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Exports"]) router.include_router(shared_routes.router, prefix=prefix, tags=["Recipe: Shared"]) +router.include_router(timeline_events.events_router, prefix=prefix, tags=["Recipe: Timeline"]) diff --git a/mealie/routes/recipe/timeline_events.py b/mealie/routes/recipe/timeline_events.py new file mode 100644 index 000000000000..445112716c82 --- /dev/null +++ b/mealie/routes/recipe/timeline_events.py @@ -0,0 +1,146 @@ +from functools import cached_property + +from fastapi import Depends, HTTPException +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, + RecipeTimelineEventOut, + RecipeTimelineEventPagination, + RecipeTimelineEventUpdate, +) +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") + + +@controller(events_router) +class RecipeTimelineEventsController(BaseCrudController): + @cached_property + def repo(self): + return self.repos.recipe_timeline_events + + @cached_property + def mixins(self): + return HttpRepo[RecipeTimelineEventCreate, RecipeTimelineEventOut, RecipeTimelineEventUpdate]( + self.repo, + self.logger, + 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 + + response = self.repo.page_all( + pagination=q, + override=RecipeTimelineEventOut, + ) + + response.set_pagination_guides(events_router.url_path_for("get_all", slug=slug), q.dict()) + return response + + @events_router.post("", response_model=RecipeTimelineEventOut, status_code=201) + def create_one(self, slug: str, 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) + event = self.mixins.create_one(event_data) + + self.publish_event( + event_type=EventTypes.recipe_updated, + document_data=EventRecipeTimelineEventData( + operation=EventOperation.create, 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), + ), + ) + + 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 + + @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") + + 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), + ), + ) + + 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") + + 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), + ), + ) + + return event diff --git a/mealie/schema/recipe/__init__.py b/mealie/schema/recipe/__init__.py index aaa399872a20..8b97aa3ea85e 100644 --- a/mealie/schema/recipe/__init__.py +++ b/mealie/schema/recipe/__init__.py @@ -70,6 +70,13 @@ from .recipe_scraper import ScrapeRecipe, ScrapeRecipeTest from .recipe_settings import RecipeSettings from .recipe_share_token import RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenSave, RecipeShareTokenSummary from .recipe_step import IngredientReferences, RecipeStep +from .recipe_timeline_events import ( + RecipeTimelineEventCreate, + RecipeTimelineEventIn, + RecipeTimelineEventOut, + RecipeTimelineEventPagination, + RecipeTimelineEventUpdate, +) from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave from .request_helpers import RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse @@ -78,6 +85,11 @@ __all__ = [ "RecipeToolOut", "RecipeToolResponse", "RecipeToolSave", + "RecipeTimelineEventCreate", + "RecipeTimelineEventIn", + "RecipeTimelineEventOut", + "RecipeTimelineEventPagination", + "RecipeTimelineEventUpdate", "RecipeAsset", "RecipeSettings", "RecipeShareToken", diff --git a/mealie/schema/recipe/recipe_timeline_events.py b/mealie/schema/recipe/recipe_timeline_events.py new file mode 100644 index 000000000000..d92e2c993dc4 --- /dev/null +++ b/mealie/schema/recipe/recipe_timeline_events.py @@ -0,0 +1,53 @@ +from datetime import datetime +from enum import Enum + +from pydantic import UUID4 + +from mealie.schema._mealie.mealie_model import MealieModel +from mealie.schema.response.pagination import PaginationBase + + +class TimelineEventType(Enum): + system = "system" + info = "info" + comment = "comment" + + +class RecipeTimelineEventIn(MealieModel): + user_id: UUID4 | None = None + """can be inferred in some contexts, so it's not required""" + + subject: str + event_type: TimelineEventType + + message: str | None = None + image: str | None = None + + timestamp: datetime = datetime.now() + + class Config: + use_enum_values = True + + +class RecipeTimelineEventCreate(RecipeTimelineEventIn): + recipe_id: UUID4 + user_id: UUID4 + + +class RecipeTimelineEventUpdate(MealieModel): + subject: str + message: str | None = None + image: str | None = None + + +class RecipeTimelineEventOut(RecipeTimelineEventCreate): + id: UUID4 + created_at: datetime + update_at: datetime + + class Config: + orm_mode = True + + +class RecipeTimelineEventPagination(PaginationBase): + items: list[RecipeTimelineEventOut] diff --git a/mealie/services/event_bus_service/event_types.py b/mealie/services/event_bus_service/event_types.py index 434f56f70cce..9b354baf17bb 100644 --- a/mealie/services/event_bus_service/event_types.py +++ b/mealie/services/event_bus_service/event_types.py @@ -64,6 +64,7 @@ class EventDocumentType(Enum): shopping_list_item = "shopping_list_item" recipe = "recipe" recipe_bulk_report = "recipe_bulk_report" + recipe_timeline_event = "recipe_timeline_event" tag = "tag" @@ -123,6 +124,12 @@ class EventRecipeBulkReportData(EventDocumentDataBase): report_id: UUID4 +class EventRecipeTimelineEventData(EventDocumentDataBase): + document_type = EventDocumentType.recipe_timeline_event + recipe_slug: str + recipe_timeline_event_id: UUID4 + + class EventTagData(EventDocumentDataBase): document_type = EventDocumentType.tag tag_id: UUID4 diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index e1ea244415ad..d7f958e6a059 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -1,5 +1,6 @@ import json import shutil +from datetime import datetime from pathlib import Path from shutil import copytree, rmtree from typing import Union @@ -13,6 +14,7 @@ from mealie.schema.recipe.recipe import CreateRecipe, Recipe from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_settings import RecipeSettings from mealie.schema.recipe.recipe_step import RecipeStep +from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType from mealie.schema.user.user import GroupInDB, PrivateUser from mealie.services._base_service import BaseService from mealie.services.recipe.recipe_data_service import RecipeDataService @@ -132,7 +134,19 @@ class RecipeService(BaseService): else: data.settings = RecipeSettings() - return self.repos.recipes.create(data) + new_recipe = self.repos.recipes.create(data) + + # create first timeline entry + timeline_event_data = RecipeTimelineEventCreate( + user_id=new_recipe.user_id, + recipe_id=new_recipe.id, + subject="Recipe Created", + event_type=TimelineEventType.system, + timestamp=new_recipe.created_at or datetime.now(), + ) + + self.repos.recipe_timeline_events.create(timeline_event_data) + return new_recipe def create_from_zip(self, archive: UploadFile, temp_path: Path) -> Recipe: """ 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 new file mode 100644 index 000000000000..201d50bfab7d --- /dev/null +++ b/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py @@ -0,0 +1,252 @@ +import pytest +from fastapi.testclient import TestClient + +from mealie.schema.recipe.recipe import Recipe +from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventOut, RecipeTimelineEventPagination +from tests.utils import api_routes +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +@pytest.fixture(scope="function") +def recipes(api_client: TestClient, unique_user: TestUser): + recipes = [] + for _ in range(3): + data = {"name": random_string(10)} + response = api_client.post(api_routes.recipes, json=data, headers=unique_user.token) + + assert response.status_code == 201 + slug = response.json() + + response = api_client.get(f"{api_routes.recipes}/{slug}", headers=unique_user.token) + assert response.status_code == 200 + + recipe = Recipe.parse_obj(response.json()) + recipes.append(recipe) + + yield recipes + response = api_client.delete(f"{api_routes.recipes}/{slug}", headers=unique_user.token) + + +def test_create_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]): + recipe = recipes[0] + new_event = { + "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, + headers=unique_user.token, + ) + assert event_response.status_code == 201 + + event = RecipeTimelineEventOut.parse_obj(event_response.json()) + assert event.recipe_id == recipe.id + assert str(event.user_id) == str(unique_user.user_id) + + +def test_get_all_timeline_events(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]): + # create some events + recipe = recipes[0] + events_data = [ + { + "user_id": unique_user.user_id, + "subject": random_string(), + "event_type": "info", + "message": random_string(), + } + for _ in range(10) + ] + + events: list[RecipeTimelineEventOut] = [] + for event_data in events_data: + event_response = api_client.post( + api_routes.recipes_slug_timeline_events(recipe.slug), 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_pagination = RecipeTimelineEventPagination.parse_obj(events_response.json()) + + event_ids = [event.id for event in events] + paginated_event_ids = [event.id for event in events_pagination.items] + + assert len(event_ids) <= len(paginated_event_ids) + for event_id in event_ids: + assert event_id in paginated_event_ids + + +def test_get_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]): + # create an event + recipe = recipes[0] + 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, + ) + 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 + ) + assert event_response.status_code == 200 + + event = RecipeTimelineEventOut.parse_obj(event_response.json()) + assert event == new_event + + +def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]): + old_subject = random_string() + new_subject = random_string() + + # create an event + recipe = recipes[0] + new_event_data = { + "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 + ) + new_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + assert new_event.subject == old_subject + + # update the event + updated_event_data = {"subject": new_subject} + + event_response = api_client.put( + api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id), + json=updated_event_data, + headers=unique_user.token, + ) + assert event_response.status_code == 200 + + updated_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + assert new_event.id == updated_event.id + assert updated_event.subject == new_subject + + +def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]): + # create an event + recipe = recipes[0] + 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 + ) + 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 + ) + assert event_response.status_code == 200 + + deleted_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + assert deleted_event.id == new_event.id + + # 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 + ) + assert event_response.status_code == 404 + + +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 + ) + events_pagination = RecipeTimelineEventPagination.parse_obj(events_response.json()) + assert events_pagination.items + + +def test_invalid_recipe_slug(api_client: TestClient, unique_user: TestUser): + 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(random_string()), 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), + json=new_event_data, + 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), + json=new_event_data, + 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), + json=new_event_data, + 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/services_tests/backup_v2_tests/test_alchemy_exporter.py b/tests/unit_tests/services_tests/backup_v2_tests/test_alchemy_exporter.py index 7556f2178d0c..7e8475832df2 100644 --- a/tests/unit_tests/services_tests/backup_v2_tests/test_alchemy_exporter.py +++ b/tests/unit_tests/services_tests/backup_v2_tests/test_alchemy_exporter.py @@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter ALEMBIC_VERSIONS = [ - {"version_num": "44e8d670719d"}, + {"version_num": "2ea7a807915c"}, ] diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py index c294935b0a1e..c256e0f8144c 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -364,6 +364,16 @@ def recipes_slug_image(slug): return f"{prefix}/recipes/{slug}/image" +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 shared_recipes_item_id(item_id): """`/api/shared/recipes/{item_id}`""" return f"{prefix}/shared/recipes/{item_id}"