mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-31 20:00:38 -05:00
related recipes included when adding mealplan to shopping list
This commit is contained in:
@@ -490,8 +490,8 @@ class ShoppingPreferenceForm(forms.ModelForm):
|
||||
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
|
||||
),
|
||||
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
|
||||
'mealplan_autoexclude_onhand': _('When automatically adding a meal plan to the shopping list, exclude ingredients that are on hand.'),
|
||||
'mealplan_autoinclude_related': _('When automatically adding a meal plan to the shopping list, include all related recipes.'),
|
||||
'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'),
|
||||
'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'),
|
||||
'default_delay': _('Default number of hours to delay a shopping list entry.'),
|
||||
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.db.models import F, OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
|
||||
from cookbook.models import UserPreference
|
||||
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||
from cookbook.models import (Ingredient, ShoppingListEntry, ShoppingListRecipe,
|
||||
SupermarketCategoryRelation)
|
||||
from recipes import settings
|
||||
|
||||
|
||||
def shopping_helper(qs, request):
|
||||
@@ -35,3 +39,77 @@ def shopping_helper(qs, request):
|
||||
supermarket_order = ['checked'] + supermarket_order
|
||||
|
||||
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
|
||||
|
||||
|
||||
def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
|
||||
"""
|
||||
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
|
||||
:param list_recipe: Modify an existing ShoppingListRecipe
|
||||
:param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
|
||||
:param mealplan: alternatively use a mealplan recipe as source of ingredients
|
||||
:param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
|
||||
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
|
||||
:param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
|
||||
"""
|
||||
# TODO cascade to related recipes
|
||||
r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
|
||||
if not r:
|
||||
raise ValueError(_("You must supply a recipe or mealplan"))
|
||||
|
||||
created_by = created_by or getattr(mealplan, 'created_by', None) or getattr(list_recipe, 'created_by', None)
|
||||
if not created_by:
|
||||
raise ValueError(_("You must supply a created_by"))
|
||||
|
||||
if type(servings) not in [int, float]:
|
||||
servings = getattr(mealplan, 'servings', 1.0)
|
||||
|
||||
shared_users = list(created_by.get_shopping_share())
|
||||
shared_users.append(created_by)
|
||||
if list_recipe:
|
||||
created = False
|
||||
else:
|
||||
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
|
||||
created = True
|
||||
|
||||
if servings == 0 and not created:
|
||||
list_recipe.delete()
|
||||
return []
|
||||
elif ingredients:
|
||||
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
|
||||
else:
|
||||
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
|
||||
|
||||
add_ingredients = ingredients.values_list('id', flat=True)
|
||||
if not append:
|
||||
existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
||||
# delete shopping list entries not included in ingredients
|
||||
existing_list.exclude(ingredient__in=ingredients).delete()
|
||||
# add shopping list entries that did not previously exist
|
||||
add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
|
||||
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
|
||||
|
||||
# if servings have changed, update the ShoppingListRecipe and existing Entrys
|
||||
if servings <= 0:
|
||||
servings = 1
|
||||
servings_factor = servings / r.servings
|
||||
if not created and list_recipe.servings != servings:
|
||||
update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
|
||||
for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
|
||||
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
|
||||
sle.save()
|
||||
|
||||
# add any missing Entrys
|
||||
for i in [x for x in add_ingredients if not x.food.ignore_shopping]:
|
||||
|
||||
ShoppingListEntry.objects.create(
|
||||
list_recipe=list_recipe,
|
||||
food=i.food,
|
||||
unit=i.unit,
|
||||
ingredient=i,
|
||||
amount=i.amount * Decimal(servings_factor),
|
||||
created_by=created_by,
|
||||
space=space,
|
||||
)
|
||||
|
||||
# return all shopping list items
|
||||
return list_recipe
|
||||
|
||||
@@ -783,17 +783,6 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
# TODO override create method to check if recipes are always added
|
||||
# @classmethod
|
||||
# def generate_shoppinglist(self, ingredients=None):
|
||||
# recipe_list = ShoppingListRecipe.objects.create()
|
||||
# if not ingredients:
|
||||
# ingredients = Ingredient.objects.filter(step__recipe=self.recipe)
|
||||
# for i in ingredients:
|
||||
# ShoppingListEntry.objects.create(
|
||||
|
||||
# )
|
||||
|
||||
def get_label(self):
|
||||
if self.title:
|
||||
return self.title
|
||||
@@ -847,149 +836,6 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
@classmethod
|
||||
@atomic
|
||||
def list_from_recipe(self, list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None):
|
||||
"""
|
||||
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
|
||||
:param list_recipe: Modify an existing ShoppingListRecipe
|
||||
:param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
|
||||
:param mealplan: alternatively use a mealplan recipe as source of ingredients
|
||||
:param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
|
||||
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
|
||||
"""
|
||||
# TODO cascade to related recipes
|
||||
r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
|
||||
if not r:
|
||||
raise ValueError(_("You must supply a recipe or mealplan"))
|
||||
|
||||
created_by = created_by or getattr(mealplan, 'created_by', None) or getattr(list_recipe, 'created_by', None)
|
||||
if not created_by:
|
||||
raise ValueError(_("You must supply a created_by"))
|
||||
|
||||
if type(servings) not in [int, float]:
|
||||
servings = getattr(mealplan, 'servings', 1.0)
|
||||
|
||||
shared_users = list(created_by.get_shopping_share())
|
||||
shared_users.append(created_by)
|
||||
if list_recipe:
|
||||
created = False
|
||||
else:
|
||||
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
|
||||
created = True
|
||||
|
||||
if servings == 0 and not created:
|
||||
list_recipe.delete()
|
||||
return []
|
||||
elif ingredients:
|
||||
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
|
||||
else:
|
||||
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
|
||||
existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
||||
# delete shopping list entries not included in ingredients
|
||||
existing_list.exclude(ingredient__in=ingredients).delete()
|
||||
# add shopping list entries that did not previously exist
|
||||
add_ingredients = set(ingredients.values_list('id', flat=True)) - set(existing_list.values_list('ingredient__id', flat=True))
|
||||
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
|
||||
|
||||
# if servings have changed, update the ShoppingListRecipe and existing Entrys
|
||||
if servings <= 0:
|
||||
servings = 1
|
||||
servings_factor = servings / r.servings
|
||||
if not created and list_recipe.servings != servings:
|
||||
update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
|
||||
for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
|
||||
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
|
||||
sle.save()
|
||||
|
||||
# add any missing Entrys
|
||||
shoppinglist = [
|
||||
ShoppingListEntry(
|
||||
list_recipe=list_recipe,
|
||||
food=i.food,
|
||||
unit=i.unit,
|
||||
ingredient=i,
|
||||
amount=i.amount * Decimal(servings),
|
||||
created_by=created_by,
|
||||
space=space
|
||||
)
|
||||
for i in ingredients
|
||||
]
|
||||
return ShoppingListEntry.objects.bulk_create(shoppinglist)
|
||||
|
||||
@classmethod
|
||||
@atomic
|
||||
def list_from_recipe(self, list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None):
|
||||
"""
|
||||
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
|
||||
:param list_recipe: Modify an existing ShoppingListRecipe
|
||||
:param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
|
||||
:param mealplan: alternatively use a mealplan recipe as source of ingredients
|
||||
:param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
|
||||
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
|
||||
"""
|
||||
# TODO cascade to related recipes
|
||||
r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
|
||||
if not r:
|
||||
raise ValueError(_("You must supply a recipe or mealplan"))
|
||||
|
||||
created_by = created_by or getattr(mealplan, 'created_by', None) or getattr(list_recipe, 'created_by', None)
|
||||
if not created_by:
|
||||
raise ValueError(_("You must supply a created_by"))
|
||||
|
||||
if type(servings) not in [int, float]:
|
||||
servings = getattr(mealplan, 'servings', 1.0)
|
||||
|
||||
shared_users = list(created_by.get_shopping_share())
|
||||
shared_users.append(created_by)
|
||||
if list_recipe:
|
||||
created = False
|
||||
else:
|
||||
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
|
||||
created = True
|
||||
|
||||
if servings == 0 and not created:
|
||||
list_recipe.delete()
|
||||
return []
|
||||
elif ingredients:
|
||||
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
|
||||
else:
|
||||
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
|
||||
existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
||||
# delete shopping list entries not included in ingredients
|
||||
existing_list.exclude(ingredient__in=ingredients).delete()
|
||||
# add shopping list entries that did not previously exist
|
||||
add_ingredients = set(ingredients.values_list('id', flat=True)) - set(existing_list.values_list('ingredient__id', flat=True))
|
||||
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
|
||||
|
||||
# if servings have changed, update the ShoppingListRecipe and existing Entrys
|
||||
if servings <= 0:
|
||||
servings = 1
|
||||
servings_factor = servings / r.servings
|
||||
if not created and list_recipe.servings != servings:
|
||||
update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
|
||||
for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
|
||||
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
|
||||
sle.save()
|
||||
|
||||
# add any missing Entrys
|
||||
shoppinglist = [
|
||||
ShoppingListEntry(
|
||||
list_recipe=list_recipe,
|
||||
food=i.food,
|
||||
unit=i.unit,
|
||||
ingredient=i,
|
||||
amount=i.amount * Decimal(servings_factor),
|
||||
created_by=created_by,
|
||||
space=space
|
||||
)
|
||||
for i in [x for x in add_ingredients if not x.food.ignore_shopping]
|
||||
]
|
||||
ShoppingListEntry.objects.bulk_create(shoppinglist)
|
||||
# return all shopping list items
|
||||
print('end of servings')
|
||||
return ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
||||
|
||||
@ staticmethod
|
||||
def get_space_key():
|
||||
return 'shoppinglist', 'space'
|
||||
|
||||
@@ -12,6 +12,7 @@ from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
|
||||
from cookbook.helper.shopping_helper import list_from_recipe
|
||||
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
|
||||
FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
|
||||
NutritionInformation, Recipe, RecipeBook, RecipeBookEntry,
|
||||
@@ -616,7 +617,7 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
mealplan = super().create(validated_data)
|
||||
if self.context['request'].data.get('addshopping', False):
|
||||
ShoppingListEntry.list_from_recipe(mealplan=mealplan, space=validated_data['space'], created_by=validated_data['created_by'])
|
||||
list_from_recipe(mealplan=mealplan, space=validated_data['space'], created_by=validated_data['created_by'])
|
||||
return mealplan
|
||||
|
||||
class Meta:
|
||||
@@ -648,7 +649,7 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if 'servings' in validated_data:
|
||||
ShoppingListEntry.list_from_recipe(
|
||||
list_from_recipe(
|
||||
list_recipe=instance,
|
||||
servings=validated_data['servings'],
|
||||
created_by=self.context['request'].user,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from decimal import Decimal
|
||||
from functools import wraps
|
||||
|
||||
from django.contrib.postgres.search import SearchVector
|
||||
@@ -5,8 +6,10 @@ from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import translation
|
||||
|
||||
from cookbook.helper.shopping_helper import list_from_recipe
|
||||
from cookbook.managers import DICTIONARY
|
||||
from cookbook.models import Food, FoodInheritField, Recipe, Step
|
||||
from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe,
|
||||
ShoppingListEntry, Step)
|
||||
|
||||
|
||||
# wraps a signal with the ability to set 'skip_signal' to avoid creating recursive signals
|
||||
@@ -78,3 +81,52 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs):
|
||||
# don't cascade empty supermarket category
|
||||
if instance.supermarket_category:
|
||||
instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='supermarket_category').update(supermarket_category=instance.supermarket_category)
|
||||
|
||||
|
||||
@receiver(post_save, sender=MealPlan)
|
||||
def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs):
|
||||
user = instance.get_owner()
|
||||
if not created or not user.userpreference.mealplan_autoadd_shopping:
|
||||
return
|
||||
|
||||
# if creating a mealplan - perform shopping list activities
|
||||
space = instance.space
|
||||
if user.userpreference.mealplan_autoadd_shopping:
|
||||
kwargs = {
|
||||
'mealplan': instance,
|
||||
'space': space,
|
||||
'created_by': user,
|
||||
'servings': instance.servings
|
||||
}
|
||||
recipe_ingredients = Ingredient.objects.filter(step__recipe=instance.recipe, space=space)
|
||||
if exclude_onhand := user.userpreference.mealplan_autoexclude_onhand:
|
||||
recipe_ingredients = recipe_ingredients.exclude(food__on_hand=True)
|
||||
if related := user.userpreference.mealplan_autoinclude_related:
|
||||
# TODO: add levels of related recipes to use when auto-adding mealplans
|
||||
related_recipes = instance.recipe.get_related_recipes()
|
||||
# dont' add recipes that are going to have their recipes added to the shopping list
|
||||
kwargs['ingredients'] = recipe_ingredients.exclude(food__recipe__in=related_recipes).values_list('id', flat=True)
|
||||
else:
|
||||
kwargs['ingredients'] = recipe_ingredients.values_list('id', flat=True)
|
||||
|
||||
list_recipe = list_from_recipe(**kwargs)
|
||||
if related:
|
||||
servings_factor = Decimal(instance.servings / instance.recipe.servings)
|
||||
kwargs['list_recipe'] = list_recipe
|
||||
food_recipes = recipe_ingredients.filter(food__recipe__in=related_recipes).values('food__recipe', 'amount')
|
||||
|
||||
for recipe in related_recipes:
|
||||
kwargs['ingredients'] = []
|
||||
if exclude_onhand:
|
||||
kwargs['ingredients'] = Ingredient.objects.filter(step__recipe=recipe, food__on_hand=False, space=space).values_list('id', flat=True)
|
||||
kwargs['recipe'] = recipe
|
||||
|
||||
# assume related recipes are intended to be 'full sized' to parent recipe
|
||||
# Recipe1 (servings:4) includes StepRecipe2(servings:2) a Meal Plan serving size of 8 would assume 4 servings of StepRecipe2
|
||||
if recipe.id in [x['food__recipe'] for x in food_recipes if x['food__recipe'] == recipe.id]:
|
||||
kwargs['servings'] = Decimal(recipe.servings) * sum([x['amount'] for x in food_recipes if x['food__recipe'] == recipe.id]) * servings_factor
|
||||
else:
|
||||
# TODO: When modifying step recipes to allow serving size - will need to update this
|
||||
kwargs['servings'] = Decimal(recipe.servings) * servings_factor
|
||||
|
||||
list_from_recipe(**kwargs, append=True)
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
window.location.hash = e.target.hash;
|
||||
})
|
||||
// listen for events
|
||||
$(document).ready(function(){
|
||||
{% comment %} $(document).ready(function(){
|
||||
hideShow()
|
||||
// call hideShow when the user clicks on the mealplan_autoadd checkbox
|
||||
$("#id_shopping-mealplan_autoadd_shopping").click(function(event){
|
||||
@@ -261,7 +261,7 @@
|
||||
{
|
||||
$('#div_id_shopping-mealplan_autoexclude_onhand').hide();
|
||||
$('#div_id_shopping-mealplan_autoinclude_related').hide();
|
||||
}
|
||||
} {% endcomment %}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -38,7 +38,7 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus
|
||||
from cookbook.helper.recipe_html_import import get_recipe_from_source
|
||||
from cookbook.helper.recipe_search import get_facet, old_search, search_recipes
|
||||
from cookbook.helper.recipe_url_import import get_from_scraper
|
||||
from cookbook.helper.shopping_helper import shopping_helper
|
||||
from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper
|
||||
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
|
||||
ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook,
|
||||
RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry,
|
||||
@@ -658,7 +658,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
list_recipe = request.data.get('list_recipe', None)
|
||||
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
|
||||
# TODO: Consider if this should be a Recipe method
|
||||
ShoppingListEntry.list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=request.user)
|
||||
list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=request.user)
|
||||
|
||||
return Response(content, status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user