Fix after rebase
This commit is contained in:
@ -35,7 +35,20 @@ def get_user_name(self):
|
||||
return self.username
|
||||
|
||||
|
||||
def get_shopping_share(self):
|
||||
# get list of users that shared shopping list with user. Django ORM forbids this type of query, so raw is required
|
||||
return User.objects.raw(' '.join([
|
||||
'SELECT auth_user.id FROM auth_user',
|
||||
'INNER JOIN cookbook_userpreference',
|
||||
'ON (auth_user.id = cookbook_userpreference.user_id)',
|
||||
'INNER JOIN cookbook_userpreference_shopping_share',
|
||||
'ON (cookbook_userpreference.user_id = cookbook_userpreference_shopping_share.userpreference_id)',
|
||||
'WHERE cookbook_userpreference_shopping_share.user_id ={}'.format(self.id)
|
||||
]))
|
||||
|
||||
|
||||
auth.models.User.add_to_class('get_user_name', get_user_name)
|
||||
auth.models.User.add_to_class('get_shopping_share', get_shopping_share)
|
||||
|
||||
|
||||
def get_model_name(model):
|
||||
@ -78,6 +91,13 @@ class TreeModel(MP_Node):
|
||||
else:
|
||||
return f"{self.name}"
|
||||
|
||||
# MP_Tree move uses raw SQL to execute move, override behavior to force a save triggering post_save signal
|
||||
def move(self, *args, **kwargs):
|
||||
super().move(*args, **kwargs)
|
||||
# treebeard bypasses ORM, need to retrieve the object again to avoid writing previous state back to disk
|
||||
obj = self.__class__.objects.get(id=self.id)
|
||||
obj.save()
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
parent = self.get_parent()
|
||||
@ -124,6 +144,47 @@ class TreeModel(MP_Node):
|
||||
with scopes_disabled():
|
||||
return super().add_root(**kwargs)
|
||||
|
||||
# i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet
|
||||
def include_descendants(queryset=None, filter=None):
|
||||
"""
|
||||
:param queryset: Model Queryset to add descendants
|
||||
:param filter: Filter (exclude) the descendants nodes with the provided Q filter
|
||||
"""
|
||||
descendants = Q()
|
||||
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
|
||||
nodes = queryset.values('path', 'depth')
|
||||
for node in nodes:
|
||||
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
|
||||
|
||||
return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | descendants)
|
||||
|
||||
def exclude_descendants(queryset=None, filter=None):
|
||||
"""
|
||||
:param queryset: Model Queryset to add descendants
|
||||
:param filter: Filter (include) the descendants nodes with the provided Q filter
|
||||
"""
|
||||
descendants = Q()
|
||||
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
|
||||
nodes = queryset.values('path', 'depth')
|
||||
for node in nodes:
|
||||
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
|
||||
|
||||
return queryset.model.objects.filter(id__in=queryset.values_list('id')).exclude(descendants)
|
||||
|
||||
def include_ancestors(queryset=None):
|
||||
"""
|
||||
:param queryset: Model Queryset to add ancestors
|
||||
:param filter: Filter (include) the ancestors nodes with the provided Q filter
|
||||
"""
|
||||
|
||||
queryset = queryset.annotate(root=Substr('path', 1, queryset.model.steplen))
|
||||
nodes = list(set(queryset.values_list('root', 'depth')))
|
||||
|
||||
ancestors = Q()
|
||||
for node in nodes:
|
||||
ancestors |= Q(path__startswith=node[0], depth__lt=node[1])
|
||||
return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | ancestors)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@ -157,6 +218,18 @@ class PermissionModelMixin:
|
||||
raise NotImplementedError('get space for method not implemented and standard fields not available')
|
||||
|
||||
|
||||
class FoodInheritField(models.Model, PermissionModelMixin):
|
||||
field = models.CharField(max_length=32, unique=True)
|
||||
name = models.CharField(max_length=64, unique=True)
|
||||
|
||||
def __str__(self):
|
||||
return _(self.name)
|
||||
|
||||
@staticmethod
|
||||
def get_name(self):
|
||||
return _(self.name)
|
||||
|
||||
|
||||
class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
name = models.CharField(max_length=128, default='Default')
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
|
||||
@ -167,6 +240,7 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
max_users = models.IntegerField(default=0)
|
||||
allow_sharing = models.BooleanField(default=True)
|
||||
demo = models.BooleanField(default=False)
|
||||
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -245,10 +319,18 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
plan_share = models.ManyToManyField(
|
||||
User, blank=True, related_name='plan_share_default'
|
||||
)
|
||||
shopping_share = models.ManyToManyField(
|
||||
User, blank=True, related_name='shopping_share'
|
||||
)
|
||||
ingredient_decimals = models.IntegerField(default=2)
|
||||
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
|
||||
shopping_auto_sync = models.IntegerField(default=5)
|
||||
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
|
||||
mealplan_autoadd_shopping = models.BooleanField(default=False)
|
||||
mealplan_autoexclude_onhand = models.BooleanField(default=True)
|
||||
mealplan_autoinclude_related = models.BooleanField(default=True)
|
||||
filter_to_supermarket = models.BooleanField(default=False)
|
||||
default_delay = models.IntegerField(default=4)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
|
||||
@ -363,8 +445,8 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
|
||||
name = models.CharField(max_length=64)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
description = models.TextField(default="", blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True) # TODO deprecate
|
||||
updated_at = models.DateTimeField(auto_now=True) # TODO deprecate
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
||||
@ -393,6 +475,10 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
||||
|
||||
|
||||
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
# exclude fields not implemented yet
|
||||
inherit_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', 'substitute_children', 'substitute_siblings'])
|
||||
|
||||
# WARNING: Food inheritance relies on post_save signals, avoid using UPDATE to update Food objects unless you intend to bypass those signals
|
||||
if SORT_TREE_BY_NAME:
|
||||
node_order_by = ['name']
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
@ -400,6 +486,9 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
ignore_shopping = models.BooleanField(default=False)
|
||||
description = models.TextField(default='', blank=True)
|
||||
on_hand = models.BooleanField(default=False)
|
||||
inherit = models.BooleanField(default=False)
|
||||
ignore_inherit = models.ManyToManyField(FoodInheritField, blank=True) # is this better as inherit instead of ignore inherit? which is more intuitive?
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
||||
@ -413,6 +502,38 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
else:
|
||||
return super().delete()
|
||||
|
||||
@staticmethod
|
||||
def reset_inheritance(space=None):
|
||||
inherit = space.food_inherit.all()
|
||||
ignore_inherit = Food.inherit_fields.difference(inherit)
|
||||
|
||||
# food is going to inherit attributes
|
||||
if space.food_inherit.all().count() > 0:
|
||||
# using update to avoid creating a N*depth! save signals
|
||||
Food.objects.filter(space=space).update(inherit=True)
|
||||
# ManyToMany cannot be updated through an UPDATE operation
|
||||
Through = Food.objects.first().ignore_inherit.through
|
||||
Through.objects.all().delete()
|
||||
for i in ignore_inherit:
|
||||
Through.objects.bulk_create([
|
||||
Through(food_id=x, foodinheritfield_id=i.id)
|
||||
for x in Food.objects.filter(space=space).values_list('id', flat=True)
|
||||
])
|
||||
|
||||
inherit = inherit.values_list('field', flat=True)
|
||||
if 'ignore_shopping' in inherit:
|
||||
# get food at root that have children that need updated
|
||||
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, ignore_shopping=True)).update(ignore_shopping=True)
|
||||
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, ignore_shopping=False)).update(ignore_shopping=False)
|
||||
if 'supermarket_category' in inherit:
|
||||
# when supermarket_category is null or blank assuming it is not set and not intended to be blank for all descedants
|
||||
# find top node that has category set
|
||||
category_roots = Food.exclude_descendants(queryset=Food.objects.filter(supermarket_category__isnull=False, numchild__gt=0, space=space))
|
||||
for root in category_roots:
|
||||
root.get_descendants().update(supermarket_category=root.supermarket_category)
|
||||
else: # food is not going to inherit any attributes
|
||||
Food.objects.filter(space=space).update(inherit=False)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space')
|
||||
@ -534,6 +655,21 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_related_recipes(self, levels=1):
|
||||
# recipes for step recipe
|
||||
step_recipes = Q(id__in=self.steps.exclude(step_recipe=None).values_list('step_recipe'))
|
||||
# recipes for foods
|
||||
food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe=self).exclude(recipe=None).values_list('recipe'))
|
||||
related_recipes = Recipe.objects.filter(step_recipes | food_recipes)
|
||||
if levels == 1:
|
||||
return related_recipes
|
||||
|
||||
# this can loop over multiple levels if you update the value of related_recipes at each step (maybe an array?)
|
||||
# for now keeping it at 2 levels max, should be sufficient in 99.9% of scenarios
|
||||
sub_step_recipes = Q(id__in=Step.objects.filter(recipe__in=related_recipes.values_list('steps')).exclude(step_recipe=None).values_list('step_recipe'))
|
||||
sub_food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe__in=related_recipes).exclude(recipe=None).values_list('recipe'))
|
||||
return Recipe.objects.filter(Q(id__in=related_recipes.values_list('id')) | sub_step_recipes | sub_food_recipes)
|
||||
|
||||
class Meta():
|
||||
indexes = (
|
||||
GinIndex(fields=["name_search_vector"]),
|
||||
@ -552,7 +688,7 @@ class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionMod
|
||||
|
||||
objects = ScopedManager(space='recipe__space')
|
||||
|
||||
@staticmethod
|
||||
@ staticmethod
|
||||
def get_space_key():
|
||||
return 'recipe', 'space'
|
||||
|
||||
@ -600,7 +736,7 @@ class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, Pe
|
||||
|
||||
objects = ScopedManager(space='book__space')
|
||||
|
||||
@staticmethod
|
||||
@ staticmethod
|
||||
def get_space_key():
|
||||
return 'book', 'space'
|
||||
|
||||
@ -647,6 +783,18 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
if self.get_owner().userpreference.mealplan_autoadd_shopping:
|
||||
kwargs = {
|
||||
'mealplan': self,
|
||||
'space': self.space,
|
||||
'created_by': self.get_owner()
|
||||
}
|
||||
if self.get_owner().userpreference.mealplan_autoexclude_onhand:
|
||||
kwargs['ingredients'] = Ingredient.objects.filter(step__recipe=self.recipe, food__on_hand=False, space=self.space).values_list('id', flat=True)
|
||||
ShoppingListEntry.list_from_recipe(**kwargs)
|
||||
|
||||
def get_label(self):
|
||||
if self.title:
|
||||
return self.title
|
||||
@ -660,12 +808,14 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
|
||||
|
||||
|
||||
class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
name = models.CharField(max_length=32, blank=True, default='')
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True) # TODO make required after old shoppinglist deprecated
|
||||
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
mealplan = models.ForeignKey(MealPlan, on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
objects = ScopedManager(space='recipe__space')
|
||||
|
||||
@staticmethod
|
||||
@ staticmethod
|
||||
def get_space_key():
|
||||
return 'recipe', 'space'
|
||||
|
||||
@ -677,22 +827,101 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
|
||||
|
||||
def get_owner(self):
|
||||
try:
|
||||
return self.shoppinglist_set.first().created_by
|
||||
return self.entries.first().created_by or self.shoppinglist_set.first().created_by
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
|
||||
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries')
|
||||
food = models.ForeignKey(Food, on_delete=models.CASCADE)
|
||||
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, null=True, blank=True)
|
||||
unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE, null=True, blank=True)
|
||||
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
order = models.IntegerField(default=0)
|
||||
checked = models.BooleanField(default=False)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
delay_until = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
objects = ScopedManager(space='shoppinglist__space')
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
@staticmethod
|
||||
@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'
|
||||
|
||||
@ -702,12 +931,14 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
|
||||
def __str__(self):
|
||||
return f'Shopping list entry {self.id}'
|
||||
|
||||
# TODO deprecate
|
||||
def get_shared(self):
|
||||
return self.shoppinglist_set.first().shared.all()
|
||||
|
||||
# TODO deprecate
|
||||
def get_owner(self):
|
||||
try:
|
||||
return self.shoppinglist_set.first().created_by
|
||||
return self.created_by or self.shoppinglist_set.first().created_by
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@ -863,7 +1094,7 @@ class SearchFields(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return _(self.name)
|
||||
|
||||
@staticmethod
|
||||
@ staticmethod
|
||||
def get_name(self):
|
||||
return _(self.name)
|
||||
|
||||
|
Reference in New Issue
Block a user