From 5f7ac92c964d8db8ad350368eae62baff71fd01b Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Sat, 11 Feb 2023 13:08:53 -0600 Subject: [PATCH] feat: timeline event for mealplans (#2050) * added related user to mealplans * made timeline event message actually optional * added task to create events for mealplan recipes * replaced fk constraint ops with bulk ops * fixed event creation and adjusted query range * indentation is hard * added missing recipe id query filter * added tests --- ...5d943c64ee_add_related_user_to_mealplan.py | 43 ++++ mealie/app.py | 1 + mealie/db/models/group/mealplan.py | 3 + mealie/db/models/users/users.py | 4 + mealie/routes/groups/controller_mealplan.py | 10 +- mealie/schema/meal_plan/new_meal.py | 2 + .../schema/recipe/recipe_timeline_events.py | 2 +- mealie/services/scheduler/tasks/__init__.py | 2 + .../scheduler/tasks/create_timeline_events.py | 120 +++++++++++ .../backup_v2_tests/test_alchemy_exporter.py | 2 +- .../tasks/test_create_timeline_events.py | 186 ++++++++++++++++++ 11 files changed, 371 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/2023-01-21-16.54.44_165d943c64ee_add_related_user_to_mealplan.py create mode 100644 mealie/services/scheduler/tasks/create_timeline_events.py create mode 100644 tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py diff --git a/alembic/versions/2023-01-21-16.54.44_165d943c64ee_add_related_user_to_mealplan.py b/alembic/versions/2023-01-21-16.54.44_165d943c64ee_add_related_user_to_mealplan.py new file mode 100644 index 000000000000..c9e511b1d337 --- /dev/null +++ b/alembic/versions/2023-01-21-16.54.44_165d943c64ee_add_related_user_to_mealplan.py @@ -0,0 +1,43 @@ +"""add related user to mealplan + +Revision ID: 165d943c64ee +Revises: 167eb69066ad +Create Date: 2023-01-21 16:54:44.368768 + +""" +import sqlalchemy as sa + +import mealie.db.migration_types +from alembic import op + +# revision identifiers, used by Alembic. +revision = "165d943c64ee" +down_revision = "167eb69066ad" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("group_meal_plans", schema=None) as batch_op: + batch_op.add_column(sa.Column("user_id", mealie.db.migration_types.GUID(), nullable=True)) + batch_op.create_index(batch_op.f("ix_group_meal_plans_user_id"), ["user_id"], unique=False) + batch_op.create_foreign_key("fk_user_mealplans", "users", ["user_id"], ["id"]) + + with op.batch_alter_table("shopping_list_item_recipe_reference", schema=None) as batch_op: + batch_op.alter_column("recipe_scale", existing_type=sa.FLOAT(), nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("shopping_list_item_recipe_reference", schema=None) as batch_op: + batch_op.alter_column("recipe_scale", existing_type=sa.FLOAT(), nullable=True) + + with op.batch_alter_table("group_meal_plans", schema=None) as batch_op: + batch_op.drop_constraint("fk_user_mealplans", type_="foreignkey") + batch_op.drop_index(batch_op.f("ix_group_meal_plans_user_id")) + batch_op.drop_column("user_id") + + # ### end Alembic commands ### diff --git a/mealie/app.py b/mealie/app.py index 9a6bd5788963..f42abbd3d1a4 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -56,6 +56,7 @@ async def start_scheduler(): tasks.purge_group_registration, tasks.purge_password_reset_tokens, tasks.purge_group_data_exports, + tasks.create_mealplan_timeline_events, ) SchedulerRegistry.register_minutely( diff --git a/mealie/db/models/group/mealplan.py b/mealie/db/models/group/mealplan.py index a405dde0c86d..5d38ca1f459c 100644 --- a/mealie/db/models/group/mealplan.py +++ b/mealie/db/models/group/mealplan.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from group import Group from ..recipe import RecipeModel + from ..users import User class GroupMealPlanRules(BaseMixins, SqlAlchemyBase): @@ -47,6 +48,8 @@ class GroupMealPlan(SqlAlchemyBase, BaseMixins): group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True) group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="mealplans") + user_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("users.id"), index=True) + user: Mapped[Optional["User"]] = orm.relationship("User", back_populates="mealplans") recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True) recipe: Mapped[Optional["RecipeModel"]] = orm.relationship( diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 8bf0c8086229..c42cbf089839 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -13,6 +13,7 @@ from .user_to_favorite import users_to_favorites if TYPE_CHECKING: from ..group import Group + from ..group.mealplan import GroupMealPlan from ..recipe import RecipeComment, RecipeModel, RecipeTimelineEvent from .password_reset import PasswordResetModel @@ -68,6 +69,9 @@ class User(SqlAlchemyBase, BaseMixins): owned_recipes: Mapped[Optional["RecipeModel"]] = orm.relationship( "RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id] ) + mealplans: Mapped[Optional["GroupMealPlan"]] = orm.relationship( + "GroupMealPlan", order_by="GroupMealPlan.date", **sp_args + ) favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship( "RecipeModel", secondary=users_to_favorites, back_populates="favorited_by" diff --git a/mealie/routes/groups/controller_mealplan.py b/mealie/routes/groups/controller_mealplan.py index 9b1444718a46..b9c6a8ef8c48 100644 --- a/mealie/routes/groups/controller_mealplan.py +++ b/mealie/routes/groups/controller_mealplan.py @@ -83,7 +83,13 @@ class GroupMealplanController(BaseCrudController): try: recipe = random_recipes[0] return self.mixins.create_one( - SavePlanEntry(date=data.date, entry_type=data.entry_type, recipe_id=recipe.id, group_id=self.group_id) + SavePlanEntry( + date=data.date, + entry_type=data.entry_type, + recipe_id=recipe.id, + group_id=self.group_id, + user_id=self.user.id, + ) ) except IndexError as e: raise HTTPException( @@ -118,7 +124,7 @@ class GroupMealplanController(BaseCrudController): @router.post("", response_model=ReadPlanEntry, status_code=201) def create_one(self, data: CreatePlanEntry): - data = mapper.cast(data, SavePlanEntry, group_id=self.group.id) + data = mapper.cast(data, SavePlanEntry, group_id=self.group.id, user_id=self.user.id) result = self.mixins.create_one(data) self.publish_event( diff --git a/mealie/schema/meal_plan/new_meal.py b/mealie/schema/meal_plan/new_meal.py index b8868dab3407..b5aeae51993d 100644 --- a/mealie/schema/meal_plan/new_meal.py +++ b/mealie/schema/meal_plan/new_meal.py @@ -40,10 +40,12 @@ class CreatePlanEntry(MealieModel): class UpdatePlanEntry(CreatePlanEntry): id: int group_id: UUID + user_id: UUID | None class SavePlanEntry(CreatePlanEntry): group_id: UUID + user_id: UUID | None class Config: orm_mode = True diff --git a/mealie/schema/recipe/recipe_timeline_events.py b/mealie/schema/recipe/recipe_timeline_events.py index 83b0cf8b286c..f82466720a78 100644 --- a/mealie/schema/recipe/recipe_timeline_events.py +++ b/mealie/schema/recipe/recipe_timeline_events.py @@ -20,7 +20,7 @@ class RecipeTimelineEventIn(MealieModel): subject: str event_type: TimelineEventType - message: str | None = Field(alias="eventMessage") + message: str | None = Field(None, alias="eventMessage") image: str | None = None timestamp: datetime = datetime.now() diff --git a/mealie/services/scheduler/tasks/__init__.py b/mealie/services/scheduler/tasks/__init__.py index fd7649fba526..393b46f56fc4 100644 --- a/mealie/services/scheduler/tasks/__init__.py +++ b/mealie/services/scheduler/tasks/__init__.py @@ -1,3 +1,4 @@ +from .create_timeline_events import create_mealplan_timeline_events from .post_webhooks import post_group_webhooks from .purge_group_exports import purge_group_data_exports from .purge_password_reset import purge_password_reset_tokens @@ -5,6 +6,7 @@ from .purge_registration import purge_group_registration from .reset_locked_users import locked_user_reset __all__ = [ + "create_mealplan_timeline_events", "post_group_webhooks", "purge_password_reset_tokens", "purge_group_data_exports", diff --git a/mealie/services/scheduler/tasks/create_timeline_events.py b/mealie/services/scheduler/tasks/create_timeline_events.py new file mode 100644 index 000000000000..dbd54c81e950 --- /dev/null +++ b/mealie/services/scheduler/tasks/create_timeline_events.py @@ -0,0 +1,120 @@ +from datetime import datetime, time, timedelta, timezone + +from pydantic import UUID4 + +from mealie.db.db_setup import session_context +from mealie.repos.all_repositories import get_repositories +from mealie.schema.meal_plan.new_meal import PlanEntryType +from mealie.schema.recipe.recipe import Recipe, RecipeSummary +from mealie.schema.recipe.recipe_timeline_events import ( + RecipeTimelineEventCreate, + TimelineEventType, +) +from mealie.schema.response.pagination import PaginationQuery +from mealie.schema.user.user import DEFAULT_INTEGRATION_ID +from mealie.services.event_bus_service.event_bus_service import EventBusService +from mealie.services.event_bus_service.event_types import ( + EventOperation, + EventRecipeData, + EventRecipeTimelineEventData, + EventTypes, +) + + +def create_mealplan_timeline_events(group_id: UUID4 | None = None): + event_time = datetime.now(timezone.utc) + + with session_context() as session: + repos = get_repositories(session) + event_bus_service = EventBusService(session=session, group_id=group_id) + + timeline_events_to_create: list[RecipeTimelineEventCreate] = [] + recipes_to_update: dict[UUID4, RecipeSummary] = {} + recipe_id_to_slug_map: dict[UUID4, str] = {} + + if group_id is None: + # if not specified, we check all groups + groups_data = repos.groups.page_all(PaginationQuery(page=1, per_page=-1)) + group_ids = [group.id for group in groups_data.items] + + else: + group_ids = [group_id] + + for group_id in group_ids: + mealplans = repos.meals.get_today(group_id) + for mealplan in mealplans: + if not (mealplan.recipe and mealplan.user_id): + continue + + user = repos.users.get_one(mealplan.user_id) + if not user: + continue + + # TODO: make this translatable + if mealplan.entry_type == PlanEntryType.side: + event_subject = f"{user.full_name} made this as a side" + + else: + event_subject = f"{user.full_name} made this for {mealplan.entry_type.value}" + + query_start_time = datetime.combine(datetime.now(timezone.utc).date(), time.min) + query_end_time = query_start_time + timedelta(days=1) + query = PaginationQuery( + query_filter=( + f'recipe_id = "{mealplan.recipe_id}" ' + f'AND timestamp >= "{query_start_time.isoformat()}" ' + f'AND timestamp < "{query_end_time.isoformat()}" ' + f'AND subject = "{event_subject}"' + ) + ) + + # if this event already exists, don't create it again + events = repos.recipe_timeline_events.page_all(pagination=query) + if events.items: + continue + + # bump up the last made date + last_made = mealplan.recipe.last_made + if ( + not last_made or last_made.date() < event_time.date() + ) and mealplan.recipe_id not in recipes_to_update: + recipes_to_update[mealplan.recipe_id] = mealplan.recipe + + timeline_events_to_create.append( + RecipeTimelineEventCreate( + user_id=user.id, + subject=event_subject, + event_type=TimelineEventType.info, + timestamp=event_time, + recipe_id=mealplan.recipe_id, + ) + ) + + recipe_id_to_slug_map[mealplan.recipe_id] = mealplan.recipe.slug + + if not timeline_events_to_create: + return + + # TODO: use bulk operations + for event in timeline_events_to_create: + new_event = repos.recipe_timeline_events.create(event) + event_bus_service.dispatch( + integration_id=DEFAULT_INTEGRATION_ID, + group_id=group_id, # type: ignore + event_type=EventTypes.recipe_updated, + document_data=EventRecipeTimelineEventData( + operation=EventOperation.create, + recipe_slug=recipe_id_to_slug_map[new_event.recipe_id], + recipe_timeline_event_id=new_event.id, + ), + ) + + for recipe in recipes_to_update.values(): + recipe.last_made = event_time + repos.recipes.update(recipe.slug, recipe.cast(Recipe)) + event_bus_service.dispatch( + integration_id=DEFAULT_INTEGRATION_ID, + group_id=group_id, # type: ignore + event_type=EventTypes.recipe_updated, + document_data=EventRecipeData(operation=EventOperation.update, recipe_slug=recipe.slug), + ) 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 5b603bb03b8b..490e52a58de5 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": "167eb69066ad"}, + {"version_num": "165d943c64ee"}, ] diff --git a/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py b/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py new file mode 100644 index 000000000000..7387cc0880aa --- /dev/null +++ b/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py @@ -0,0 +1,186 @@ +from datetime import date, datetime, timedelta + +from fastapi.testclient import TestClient +from pydantic import UUID4 + +from mealie.schema.meal_plan.new_meal import CreatePlanEntry +from mealie.schema.recipe.recipe import RecipeSummary +from mealie.services.scheduler.tasks.create_timeline_events import ( + create_mealplan_timeline_events, +) +from tests import utils +from tests.utils import api_routes +from tests.utils.factories import random_int, random_string +from tests.utils.fixture_schemas import TestUser + + +def test_no_mealplans(): + # make sure this task runs successfully even if it doesn't do anything + create_mealplan_timeline_events() + + +def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser): + recipe_name = random_string(length=25) + response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token) + assert response.status_code == 201 + + response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) + recipe = RecipeSummary.parse_obj(response.json()) + recipe_id = recipe.id + assert recipe.last_made is None + + # store the number of events, so we can compare later + response = api_client.get(api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token) + response_json = response.json() + initial_event_count = len(response_json["items"]) + + new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=recipe_id).dict(by_alias=True) + new_plan["date"] = date.today().isoformat() + new_plan["recipeId"] = str(recipe_id) + + response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token) + assert response.status_code == 201 + + # run the task and check to make sure a new event was created from the mealplan + create_mealplan_timeline_events() + + params = {"page": "1", "perPage": "-1", "orderBy": "created_at", "orderDirection": "desc"} + response = api_client.get( + api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token, params=params + ) + response_json = response.json() + assert len(response_json["items"]) == initial_event_count + 1 + + # make sure the mealplan entry type is in the subject + event = response_json["items"][0] + assert new_plan["entryType"].lower() in event["subject"].lower() + + # make sure the recipe's last made date was updated + response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) + recipe = RecipeSummary.parse_obj(response.json()) + assert recipe.last_made.date() == date.today() # type: ignore + + +def test_new_mealplan_event_duplicates(api_client: TestClient, unique_user: TestUser): + recipe_name = random_string(length=25) + response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token) + assert response.status_code == 201 + + response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) + recipe = RecipeSummary.parse_obj(response.json()) + recipe_id = recipe.id + + # store the number of events, so we can compare later + response = api_client.get(api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token) + response_json = response.json() + initial_event_count = len(response_json["items"]) + + new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=recipe_id).dict(by_alias=True) + new_plan["date"] = date.today().isoformat() + new_plan["recipeId"] = str(recipe_id) + + response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token) + assert response.status_code == 201 + + # run the task multiple times and make sure we only create one event + for _ in range(3): + create_mealplan_timeline_events() + + params = {"page": "1", "perPage": "-1", "orderBy": "created_at", "orderDirection": "desc"} + response = api_client.get( + api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token, params=params + ) + response_json = response.json() + assert len(response_json["items"]) == initial_event_count + 1 + + +def test_new_mealplan_events_with_multiple_recipes(api_client: TestClient, unique_user: TestUser): + recipes: list[RecipeSummary] = [] + for _ in range(3): + recipe_name = random_string(length=25) + response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token) + assert response.status_code == 201 + + response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) + recipes.append(RecipeSummary.parse_obj(response.json())) + + # store the number of events, so we can compare later + response = api_client.get(api_routes.recipes_slug_timeline_events(str(recipes[0].slug)), headers=unique_user.token) + response_json = response.json() + initial_event_count = len(response_json["items"]) + + # create a few mealplans for each recipe + mealplan_count_by_recipe_id: dict[UUID4, int] = {} + for recipe in recipes: + mealplan_count_by_recipe_id[recipe.id] = 0 # type: ignore + for _ in range(random_int(1, 5)): + new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=str(recipe.id)).dict( + by_alias=True + ) + new_plan["date"] = date.today().isoformat() + new_plan["recipeId"] = str(recipe.id) + + response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token) + assert response.status_code == 201 + mealplan_count_by_recipe_id[recipe.id] += 1 # type: ignore + + # run the task once and make sure the event counts are correct + create_mealplan_timeline_events() + + for recipe in recipes: + target_count = initial_event_count + mealplan_count_by_recipe_id[recipe.id] # type: ignore + params = {"page": "1", "perPage": "-1", "orderBy": "created_at", "orderDirection": "desc"} + response = api_client.get( + api_routes.recipes_slug_timeline_events(recipe.slug), headers=unique_user.token, params=params + ) + response_json = response.json() + assert len(response_json["items"]) == target_count + + # run the task a few more times and confirm the counts are the same + for _ in range(3): + create_mealplan_timeline_events() + + for recipe in recipes: + target_count = initial_event_count + mealplan_count_by_recipe_id[recipe.id] # type: ignore + params = { + "page": "1", + "perPage": "-1", + "orderBy": "created_at", + "orderDirection": "desc", + } + response = api_client.get( + api_routes.recipes_slug_timeline_events(recipe.slug), headers=unique_user.token, params=params + ) + response_json = response.json() + assert len(response_json["items"]) == target_count + + +def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser): + recipe_name = random_string(length=25) + response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token) + assert response.status_code == 201 + + response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) + recipe = RecipeSummary.parse_obj(response.json()) + recipe_id = str(recipe.id) + + future_dt = datetime.now() + timedelta(days=random_int(1, 10)) + recipe.last_made = future_dt + response = api_client.put( + api_routes.recipes_slug(recipe.slug), json=utils.jsonify(recipe), headers=unique_user.token + ) + assert response.status_code == 200 + + new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=recipe_id).dict(by_alias=True) + new_plan["date"] = date.today().isoformat() + new_plan["recipeId"] = str(recipe_id) + + response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token) + assert response.status_code == 201 + + # run the task and make sure the recipe's last made date was not updated + create_mealplan_timeline_events() + + response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) + recipe = RecipeSummary.parse_obj(response.json()) + assert recipe.last_made == future_dt