mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	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>
This commit is contained in:
		| @@ -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 | ||||
|  | ||||
| """ | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
| """ | ||||
|   | ||||
| @@ -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 ### | ||||
| @@ -73,7 +73,7 @@ export const LOCALES = [ | ||||
|   { | ||||
|     name: "Norsk (Norwegian)", | ||||
|     value: "no-NO", | ||||
|     progress: 80, | ||||
|     progress: 85, | ||||
|   }, | ||||
|   { | ||||
|     name: "Nederlands (Dutch)", | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
							
								
								
									
										2
									
								
								frontend/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								frontend/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -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; | ||||
|   | ||||
| @@ -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") | ||||
|   | ||||
							
								
								
									
										38
									
								
								mealie/db/models/recipe/recipe_timeline.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								mealie/db/models/recipe/recipe_timeline.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
| @@ -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")) | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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"]) | ||||
|   | ||||
							
								
								
									
										146
									
								
								mealie/routes/recipe/timeline_events.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								mealie/routes/recipe/timeline_events.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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", | ||||
|   | ||||
							
								
								
									
										53
									
								
								mealie/schema/recipe/recipe_timeline_events.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								mealie/schema/recipe/recipe_timeline_events.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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] | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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: | ||||
|         """ | ||||
|   | ||||
| @@ -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 | ||||
| @@ -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"}, | ||||
| ] | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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}" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user