diff --git a/mealie/lang/messages/en-US.json b/mealie/lang/messages/en-US.json index 29e1ee8f7..d6be3dd2a 100644 --- a/mealie/lang/messages/en-US.json +++ b/mealie/lang/messages/en-US.json @@ -5,6 +5,13 @@ "recipe": { "unique-name-error": "Recipe names must be unique", "recipe-created": "Recipe Created", + "made-this-as-side": "{name} made this as a side", + "made-this-for-breakfast": "{name} made this for breakfast", + "made-this-for-lunch": "{name} made this for lunch", + "made-this-for-dinner": "{name} made this for dinner", + "made-this-for-snack": "{name} made this for a snack", + "made-this-for-drink": "{name} made this for a drink", + "made-this-for-dessert": "{name} made this for dessert", "recipe-image-deleted": "Recipe image deleted", "recipe-defaults": { "ingredient-note": "1 Cup Flour", diff --git a/mealie/routes/recipe/timeline_events.py b/mealie/routes/recipe/timeline_events.py index 7894e4626..e91457cba 100644 --- a/mealie/routes/recipe/timeline_events.py +++ b/mealie/routes/recipe/timeline_events.py @@ -4,6 +4,7 @@ from functools import cached_property from fastapi import Depends, File, Form, HTTPException from pydantic import UUID4 +from mealie.lang.providers import get_locale_provider from mealie.repos.all_repositories import get_repositories from mealie.routes._base import BaseCrudController, controller from mealie.routes._base.mixins import HttpRepo @@ -15,6 +16,7 @@ from mealie.schema.recipe.recipe_timeline_events import ( RecipeTimelineEventPagination, RecipeTimelineEventUpdate, TimelineEventImage, + TimelineEventType, ) from mealie.schema.recipe.request_helpers import UpdateImageResponse from mealie.schema.response.pagination import PaginationQuery @@ -43,6 +45,21 @@ class RecipeTimelineEventsController(BaseCrudController): self.registered_exceptions, ) + def _translate_event_subject(self, event: RecipeTimelineEventOut) -> None: + """Translate auto-generated event subjects stored as i18n key references. + + Subjects are stored as ``|`` (e.g. ``recipe.made-this-for-dinner|Alice``). + Falls back to en-US when the requested locale has not yet been translated. + """ + if event.event_type == TimelineEventType.info.value and "|" in event.subject: + key, _, name = event.subject.partition("|") + if key.startswith("recipe."): + translated = self.t(key, name=name) + if translated == key: + translated = get_locale_provider("en-US").t(key, name=name) + if translated != key: + event.subject = translated + @router.get("", response_model=RecipeTimelineEventPagination) def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): response = self.repo.page_all( @@ -50,6 +67,9 @@ class RecipeTimelineEventsController(BaseCrudController): override=RecipeTimelineEventOut, ) + for event in response.items: + self._translate_event_subject(event) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @@ -83,7 +103,9 @@ class RecipeTimelineEventsController(BaseCrudController): @router.get("/{item_id}", response_model=RecipeTimelineEventOut) def get_one(self, item_id: UUID4): - return self.mixins.get_one(item_id) + event = self.mixins.get_one(item_id) + self._translate_event_subject(event) + return event @router.put("/{item_id}", response_model=RecipeTimelineEventOut) def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate): diff --git a/mealie/services/scheduler/tasks/create_timeline_events.py b/mealie/services/scheduler/tasks/create_timeline_events.py index 802b82d04..6340a16f3 100644 --- a/mealie/services/scheduler/tasks/create_timeline_events.py +++ b/mealie/services/scheduler/tasks/create_timeline_events.py @@ -43,12 +43,10 @@ def _create_mealplan_timeline_events_for_household( 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" - + event_subject = f"recipe.made-this-as-side|{user.full_name}" else: - event_subject = f"{user.full_name} made this for {mealplan.entry_type.value}" + event_subject = f"recipe.made-this-for-{mealplan.entry_type.value}|{user.full_name}" query_start_time = datetime.combine(datetime.now(UTC).date(), time.min) query_end_time = query_start_time + timedelta(days=1) diff --git a/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py b/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py index 45b78874f..0d2fed0d9 100644 --- a/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py +++ b/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py @@ -13,6 +13,49 @@ from tests.utils.factories import random_int, random_string from tests.utils.fixture_schemas import TestUser +def _create_recipe_and_mealplan(api_client: TestClient, user: TestUser, entry_type: str) -> tuple[RecipeSummary, int]: + recipe_name = random_string(length=25) + response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=user.token) + assert response.status_code == 201 + + response = api_client.get(api_routes.recipes_slug(recipe_name), headers=user.token) + recipe = RecipeSummary.model_validate(response.json()) + + params = {"queryFilter": f"recipe_id={recipe.id}"} + response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=user.token) + initial_event_count = len(response.json()["items"]) + + new_plan = CreatePlanEntry(date=datetime.now(UTC).date(), entry_type=entry_type, recipe_id=recipe.id).model_dump( + by_alias=True + ) + new_plan["date"] = datetime.now(UTC).date().isoformat() + new_plan["recipeId"] = str(recipe.id) + response = api_client.post(api_routes.households_mealplans, json=new_plan, headers=user.token) + assert response.status_code == 201 + + return recipe, initial_event_count + + +def _get_mealplan_event( + api_client: TestClient, user: TestUser, recipe: RecipeSummary, initial_count: int, extra_headers: dict +) -> dict: + create_mealplan_timeline_events() + + params = { + "page": "1", + "perPage": "-1", + "orderBy": "created_at", + "orderDirection": "desc", + "queryFilter": f"recipe_id={recipe.id}", + } + response = api_client.get( + api_routes.recipes_timeline_events, headers={**user.token, **extra_headers}, params=params + ) + items = response.json()["items"] + assert len(items) == initial_count + 1 + return items[0] + + def test_no_mealplans(): # make sure this task runs successfully even if it doesn't do anything create_mealplan_timeline_events() @@ -251,3 +294,27 @@ def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=h2_user.token) household_recipe = HouseholdRecipeSummary.model_validate(response.json()) assert household_recipe.last_made is None + + +def test_mealplan_event_subject_is_translated(api_client: TestClient, unique_user: TestUser): + """Mealplan timeline event subjects are stored as i18n keys and translated at serve time.""" + # --- dinner entry type --- + recipe, initial_count = _create_recipe_and_mealplan(api_client, unique_user, "dinner") + event = _get_mealplan_event(api_client, unique_user, recipe, initial_count, {"Accept-Language": "en-US"}) + + expected = f"{unique_user.full_name} made this for dinner" + assert event["subject"] == expected, f"expected {expected!r}, got {event['subject']!r}" + + # --- side entry type uses a distinct phrase --- + recipe2, initial_count2 = _create_recipe_and_mealplan(api_client, unique_user, "side") + event2 = _get_mealplan_event(api_client, unique_user, recipe2, initial_count2, {"Accept-Language": "en-US"}) + + expected2 = f"{unique_user.full_name} made this as a side" + assert event2["subject"] == expected2, f"expected {expected2!r}, got {event2['subject']!r}" + + # --- locale fallback: fr-FR doesn't have these keys yet, should fall back to en-US --- + recipe3, initial_count3 = _create_recipe_and_mealplan(api_client, unique_user, "lunch") + event3 = _get_mealplan_event(api_client, unique_user, recipe3, initial_count3, {"Accept-Language": "fr-FR"}) + + expected3 = f"{unique_user.full_name} made this for lunch" + assert event3["subject"] == expected3, f"expected en-US fallback {expected3!r}, got {event3['subject']!r}"