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:
Michael Genson 2022-11-01 03:12:26 -05:00 committed by GitHub
parent 714a080ecb
commit 6ee64535df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 639 additions and 6 deletions

View file

@ -1,7 +1,7 @@
"""Add is_ocr_recipe column to recipes """Add is_ocr_recipe column to recipes
Revision ID: 089bfa50d0ed Revision ID: 089bfa50d0ed
Revises: f30cf048c228 Revises: 188374910655
Create Date: 2022-08-05 17:07:07.389271 Create Date: 2022-08-05 17:07:07.389271
""" """

View file

@ -1,7 +1,7 @@
"""add extras to shopping lists, list items, and ingredient foods """add extras to shopping lists, list items, and ingredient foods
Revision ID: 44e8d670719d Revision ID: 44e8d670719d
Revises: 188374910655 Revises: 089bfa50d0ed
Create Date: 2022-08-29 13:57:40.452245 Create Date: 2022-08-29 13:57:40.452245
""" """

View file

@ -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 ###

View file

@ -73,7 +73,7 @@ export const LOCALES = [
{ {
name: "Norsk (Norwegian)", name: "Norsk (Norwegian)",
value: "no-NO", value: "no-NO",
progress: 80, progress: 85,
}, },
{ {
name: "Nederlands (Dutch)", name: "Nederlands (Dutch)",

View file

@ -8,6 +8,7 @@
export type ExportTypes = "json"; export type ExportTypes = "json";
export type RegisteredParser = "nlp" | "brute"; export type RegisteredParser = "nlp" | "brute";
export type OrderDirection = "asc" | "desc"; export type OrderDirection = "asc" | "desc";
export type TimelineEventType = "system" | "info" | "comment";
export interface AssignCategories { export interface AssignCategories {
recipes: string[]; recipes: string[];
@ -340,6 +341,40 @@ export interface RecipeTagResponse {
slug: string; slug: string;
recipes?: RecipeSummary[]; 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 { export interface RecipeToolCreate {
name: string; name: string;
onHand?: boolean; onHand?: boolean;

View file

@ -18,6 +18,7 @@ import ButtonLink from "@/components/global/ButtonLink.vue";
import ContextMenu from "@/components/global/ContextMenu.vue"; import ContextMenu from "@/components/global/ContextMenu.vue";
import CrudTable from "@/components/global/CrudTable.vue"; import CrudTable from "@/components/global/CrudTable.vue";
import DevDumpJson from "@/components/global/DevDumpJson.vue"; import DevDumpJson from "@/components/global/DevDumpJson.vue";
import DropZone from "@/components/global/DropZone.vue";
import HelpIcon from "@/components/global/HelpIcon.vue"; import HelpIcon from "@/components/global/HelpIcon.vue";
import InputColor from "@/components/global/InputColor.vue"; import InputColor from "@/components/global/InputColor.vue";
import InputLabelType from "@/components/global/InputLabelType.vue"; import InputLabelType from "@/components/global/InputLabelType.vue";
@ -56,6 +57,7 @@ declare module "vue" {
ContextMenu: typeof ContextMenu; ContextMenu: typeof ContextMenu;
CrudTable: typeof CrudTable; CrudTable: typeof CrudTable;
DevDumpJson: typeof DevDumpJson; DevDumpJson: typeof DevDumpJson;
DropZone: typeof DropZone;
HelpIcon: typeof HelpIcon; HelpIcon: typeof HelpIcon;
InputColor: typeof InputColor; InputColor: typeof InputColor;
InputLabelType: typeof InputLabelType; InputLabelType: typeof InputLabelType;

View file

@ -18,6 +18,7 @@ from .ingredient import RecipeIngredient
from .instruction import RecipeInstruction from .instruction import RecipeInstruction
from .note import Note from .note import Note
from .nutrition import Nutrition from .nutrition import Nutrition
from .recipe_timeline import RecipeTimelineEvent
from .settings import RecipeSettings from .settings import RecipeSettings
from .shared import RecipeShareTokenModel from .shared import RecipeShareTokenModel
from .tag import recipes_to_tags from .tag import recipes_to_tags
@ -82,6 +83,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
"RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan" "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 # Mealie Specific
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan") settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan")
tags = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes") tags = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes")
@ -117,6 +122,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
"recipe_instructions", "recipe_instructions",
"settings", "settings",
"comments", "comments",
"timeline_events",
} }
@validates("name") @validates("name")

View 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()

View file

@ -52,6 +52,7 @@ class User(SqlAlchemyBase, BaseMixins):
tokens = orm.relationship(LongLiveToken, **sp_args) tokens = orm.relationship(LongLiveToken, **sp_args)
comments = orm.relationship("RecipeComment", **sp_args) comments = orm.relationship("RecipeComment", **sp_args)
recipe_timeline_events = orm.relationship("RecipeTimelineEvent", **sp_args)
password_reset_tokens = orm.relationship("PasswordResetModel", **sp_args) password_reset_tokens = orm.relationship("PasswordResetModel", **sp_args)
owned_recipes_id = Column(GUID, ForeignKey("recipes.id")) owned_recipes_id = Column(GUID, ForeignKey("recipes.id"))

View file

@ -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.comment import RecipeComment
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.recipe import RecipeModel 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.shared import RecipeShareTokenModel
from mealie.db.models.recipe.tag import Tag from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool 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_category import CategoryOut, TagOut
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
from mealie.schema.recipe.recipe_share_token import RecipeShareToken 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.reports.reports import ReportEntryOut, ReportOut
from mealie.schema.server import ServerTask from mealie.schema.server import ServerTask
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser
@ -123,6 +125,10 @@ class AllRepositories:
def recipe_share_tokens(self) -> RepositoryGeneric[RecipeShareToken, RecipeShareTokenModel]: def recipe_share_tokens(self) -> RepositoryGeneric[RecipeShareToken, RecipeShareTokenModel]:
return RepositoryGeneric(self.session, PK_ID, RecipeShareTokenModel, RecipeShareToken) 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 # User

View file

@ -1,6 +1,6 @@
from fastapi import APIRouter 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" 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(comments.router, prefix=prefix, tags=["Recipe: Comments"])
router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Exports"]) 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(shared_routes.router, prefix=prefix, tags=["Recipe: Shared"])
router.include_router(timeline_events.events_router, prefix=prefix, tags=["Recipe: Timeline"])

View 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

View file

@ -70,6 +70,13 @@ from .recipe_scraper import ScrapeRecipe, ScrapeRecipeTest
from .recipe_settings import RecipeSettings from .recipe_settings import RecipeSettings
from .recipe_share_token import RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenSave, RecipeShareTokenSummary from .recipe_share_token import RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenSave, RecipeShareTokenSummary
from .recipe_step import IngredientReferences, RecipeStep 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 .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave
from .request_helpers import RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse from .request_helpers import RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
@ -78,6 +85,11 @@ __all__ = [
"RecipeToolOut", "RecipeToolOut",
"RecipeToolResponse", "RecipeToolResponse",
"RecipeToolSave", "RecipeToolSave",
"RecipeTimelineEventCreate",
"RecipeTimelineEventIn",
"RecipeTimelineEventOut",
"RecipeTimelineEventPagination",
"RecipeTimelineEventUpdate",
"RecipeAsset", "RecipeAsset",
"RecipeSettings", "RecipeSettings",
"RecipeShareToken", "RecipeShareToken",

View 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]

View file

@ -64,6 +64,7 @@ class EventDocumentType(Enum):
shopping_list_item = "shopping_list_item" shopping_list_item = "shopping_list_item"
recipe = "recipe" recipe = "recipe"
recipe_bulk_report = "recipe_bulk_report" recipe_bulk_report = "recipe_bulk_report"
recipe_timeline_event = "recipe_timeline_event"
tag = "tag" tag = "tag"
@ -123,6 +124,12 @@ class EventRecipeBulkReportData(EventDocumentDataBase):
report_id: UUID4 report_id: UUID4
class EventRecipeTimelineEventData(EventDocumentDataBase):
document_type = EventDocumentType.recipe_timeline_event
recipe_slug: str
recipe_timeline_event_id: UUID4
class EventTagData(EventDocumentDataBase): class EventTagData(EventDocumentDataBase):
document_type = EventDocumentType.tag document_type = EventDocumentType.tag
tag_id: UUID4 tag_id: UUID4

View file

@ -1,5 +1,6 @@
import json import json
import shutil import shutil
from datetime import datetime
from pathlib import Path from pathlib import Path
from shutil import copytree, rmtree from shutil import copytree, rmtree
from typing import Union 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_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_settings import RecipeSettings from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.schema.recipe.recipe_step import RecipeStep 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.schema.user.user import GroupInDB, PrivateUser
from mealie.services._base_service import BaseService from mealie.services._base_service import BaseService
from mealie.services.recipe.recipe_data_service import RecipeDataService from mealie.services.recipe.recipe_data_service import RecipeDataService
@ -132,7 +134,19 @@ class RecipeService(BaseService):
else: else:
data.settings = RecipeSettings() 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: def create_from_zip(self, archive: UploadFile, temp_path: Path) -> Recipe:
""" """

View file

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

View file

@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
ALEMBIC_VERSIONS = [ ALEMBIC_VERSIONS = [
{"version_num": "44e8d670719d"}, {"version_num": "2ea7a807915c"},
] ]

View file

@ -364,6 +364,16 @@ def recipes_slug_image(slug):
return f"{prefix}/recipes/{slug}/image" 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): def shared_recipes_item_id(item_id):
"""`/api/shared/recipes/{item_id}`""" """`/api/shared/recipes/{item_id}`"""
return f"{prefix}/shared/recipes/{item_id}" return f"{prefix}/shared/recipes/{item_id}"