Coverage for opt/mealie/lib/python3.12/site-packages/mealie/routes/households/controller_mealplan.py: 100%
74 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 03:04 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 03:04 +0000
1from datetime import date
2from functools import cached_property
4from dateutil.tz import tzlocal
5from fastapi import APIRouter, Depends, HTTPException
7from mealie.core.exceptions import mealie_registered_exceptions
8from mealie.repos.all_repositories import get_repositories
9from mealie.repos.repository_meals import RepositoryMeals
10from mealie.routes._base import controller
11from mealie.routes._base.base_controllers import BaseCrudController
12from mealie.routes._base.mixins import HttpRepo
13from mealie.schema import mapper
14from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
15from mealie.schema.meal_plan.new_meal import CreateRandomEntry, PlanEntryPagination, PlanEntryType
16from mealie.schema.meal_plan.plan_rules import PlanRulesDay
17from mealie.schema.recipe.recipe import Recipe
18from mealie.schema.response.pagination import PaginationQuery
19from mealie.schema.response.responses import ErrorResponse
20from mealie.services.event_bus_service.event_types import EventMealplanCreatedData, EventTypes
22router = APIRouter(prefix="/households/mealplans", tags=["Households: Mealplans"])
25@controller(router)
26class GroupMealplanController(BaseCrudController):
27 @cached_property
28 def repo(self) -> RepositoryMeals:
29 return self.repos.meals
31 def registered_exceptions(self, ex: type[Exception]) -> str:
32 registered = {
33 **mealie_registered_exceptions(self.translator),
34 }
35 return registered.get(ex, self.t("generic.server-error"))
37 @cached_property
38 def mixins(self):
39 return HttpRepo[CreatePlanEntry, ReadPlanEntry, UpdatePlanEntry](
40 self.repo,
41 self.logger,
42 self.registered_exceptions,
43 )
45 def _get_random_recipes_from_mealplan(
46 self, plan_date: date, entry_type: PlanEntryType, limit: int = 1
47 ) -> list[Recipe]:
48 """
49 Gets rules for a mealplan and returns a list of random recipes based on the rules.
50 May return zero recipes if no recipes match the filter criteria.
52 Recipes from all households are included unless the rules specify a household filter.
53 """
55 rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(plan_date), entry_type.value)
56 cross_household_recipes = get_repositories(
57 self.session, group_id=self.group_id, household_id=None
58 ).recipes.by_user(self.user.id)
60 qf_string = " AND ".join([f"({rule.query_filter_string})" for rule in rules if rule.query_filter_string])
61 recipes_data = cross_household_recipes.page_all(
62 pagination=PaginationQuery(
63 page=1,
64 per_page=limit,
65 query_filter=qf_string,
66 order_by="random",
67 pagination_seed=self.repo._random_seed(),
68 )
69 )
70 return recipes_data.items
72 @router.get("", response_model=PlanEntryPagination)
73 def get_all(
74 self,
75 q: PaginationQuery = Depends(PaginationQuery),
76 start_date: date | None = None,
77 end_date: date | None = None,
78 ):
79 # merge start and end dates into pagination query only if either is provided
80 if start_date or end_date:
81 if not start_date:
82 date_filter = f"date <= {end_date}"
84 elif not end_date:
85 date_filter = f"date >= {start_date}"
87 else:
88 date_filter = f"date >= {start_date} AND date <= {end_date}"
90 if q.query_filter:
91 q.query_filter = f"({q.query_filter}) AND ({date_filter})"
93 else:
94 q.query_filter = date_filter
96 return self.repo.page_all(pagination=q)
98 @router.post("", response_model=ReadPlanEntry, status_code=201)
99 def create_one(self, data: CreatePlanEntry):
100 data = mapper.cast(data, SavePlanEntry, group_id=self.group_id, user_id=self.user.id)
101 result = self.mixins.create_one(data)
103 self.publish_event(
104 event_type=EventTypes.mealplan_entry_created,
105 document_data=EventMealplanCreatedData(
106 mealplan_id=result.id,
107 recipe_id=data.recipe_id,
108 recipe_name=result.recipe.name if result.recipe else None,
109 recipe_slug=result.recipe.slug if result.recipe else None,
110 date=data.date,
111 ),
112 group_id=result.group_id,
113 household_id=result.household_id,
114 message=f"Mealplan entry created for {data.date} for {data.entry_type}",
115 )
117 return result
119 @router.get("/today")
120 def get_todays_meals(self):
121 local_tz = tzlocal()
122 return self.repo.get_today(tz=local_tz)
124 @router.post("/random", response_model=ReadPlanEntry)
125 def create_random_meal(self, data: CreateRandomEntry):
126 """
127 `create_random_meal` is a route that provides the randomized functionality for mealplaners.
128 It operates by following the rules set out in the household's mealplan settings. If no settings
129 are set, it will return any random meal.
131 Refer to the mealplan settings routes for more information on how rules can be applied
132 to the random meal selector.
133 """
134 random_recipes = self._get_random_recipes_from_mealplan(data.date, data.entry_type)
135 if not random_recipes:
136 raise HTTPException(
137 status_code=404, detail=ErrorResponse.respond(message=self.t("mealplan.no-recipes-match-your-rules"))
138 )
140 recipe = random_recipes[0]
141 return self.mixins.create_one(
142 SavePlanEntry(
143 date=data.date,
144 entry_type=data.entry_type,
145 recipe_id=recipe.id,
146 group_id=self.group_id,
147 user_id=self.user.id,
148 )
149 )
151 @router.get("/{item_id}", response_model=ReadPlanEntry)
152 def get_one(self, item_id: int):
153 return self.mixins.get_one(item_id)
155 @router.put("/{item_id}", response_model=ReadPlanEntry)
156 def update_one(self, item_id: int, data: UpdatePlanEntry):
157 return self.mixins.update_one(data, item_id)
159 @router.delete("/{item_id}", response_model=ReadPlanEntry)
160 def delete_one(self, item_id: int):
161 return self.mixins.delete_one(item_id)