diff --git a/cookbook/admin.py b/cookbook/admin.py index 4c4fc522..2237c516 100644 --- a/cookbook/admin.py +++ b/cookbook/admin.py @@ -280,7 +280,7 @@ admin.site.register(ShoppingListRecipe, ShoppingListRecipeAdmin) class ShoppingListEntryAdmin(admin.ModelAdmin): - list_display = ('id', 'food', 'unit', 'list_recipe', 'checked') + list_display = ('id', 'food', 'unit', 'list_recipe', 'created_by', 'created_at', 'checked') admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin) diff --git a/cookbook/forms.py b/cookbook/forms.py index 37a32638..1801efd9 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -1,16 +1,14 @@ from django import forms from django.conf import settings from django.core.exceptions import ValidationError -from django.forms import widgets, NumberInput +from django.forms import NumberInput, widgets from django.utils.translation import gettext_lazy as _ from django_scopes import scopes_disabled from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField from hcaptcha.fields import hCaptchaField -from .models import (Comment, InviteLink, Keyword, MealPlan, Recipe, - RecipeBook, RecipeBookEntry, Storage, Sync, User, - UserPreference, MealType, Space, - SearchPreference) +from .models import (Comment, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook, + RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference) class SelectWidget(widgets.Select): @@ -19,6 +17,7 @@ class SelectWidget(widgets.Select): class MultiSelectWidget(widgets.SelectMultiple): + class Media: js = ('custom/js/form_multiselect.js',) @@ -46,8 +45,7 @@ class UserPreferenceForm(forms.ModelForm): fields = ( 'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color', 'sticky_navbar', 'default_page', 'show_recent', 'search_style', - 'plan_share', 'ingredient_decimals', 'shopping_auto_sync', - 'comments' + 'plan_share', 'ingredient_decimals', 'comments', ) labels = { @@ -75,20 +73,26 @@ class UserPreferenceForm(forms.ModelForm): # noqa: E501 'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501 'plan_share': _( - 'Users with whom newly created meal plan/shopping list entries should be shared by default.'), + 'Users with whom newly created meal plans should be shared by default.'), + 'shopping_share': _('Users with whom to share shopping lists.'), # noqa: E501 'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501 + 'ingredient_decimals': _('Number of decimals to round ingredients.'), # noqa: E501 'comments': _('If you want to be able to create and see comments underneath recipes.'), # noqa: E501 'shopping_auto_sync': _( 'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501 'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501 ), - 'sticky_navbar': _('Makes the navbar stick to the top of the page.') # noqa: E501 + 'sticky_navbar': _('Makes the navbar stick to the top of the page.'), # noqa: E501 + 'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'), + 'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'), } widgets = { - 'plan_share': MultiSelectWidget + 'plan_share': MultiSelectWidget, + 'shopping_share': MultiSelectWidget, + } @@ -262,6 +266,7 @@ class SyncForm(forms.ModelForm): } +# TODO deprecate class BatchEditForm(forms.Form): search = forms.CharField(label=_('Search String')) keywords = forms.ModelMultipleChoiceField( @@ -298,6 +303,7 @@ class ImportRecipeForm(forms.ModelForm): } +# TODO deprecate class MealPlanForm(forms.ModelForm): def __init__(self, *args, **kwargs): space = kwargs.pop('space') @@ -420,10 +426,8 @@ class UserCreateForm(forms.Form): class SearchPreferenceForm(forms.ModelForm): prefix = 'search' - trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, - widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}), - help_text=_( - 'Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).')) + trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}), + help_text=_('Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).')) preset = forms.CharField(widget=forms.HiddenInput(), required=False) class Meta: @@ -465,3 +469,59 @@ class SearchPreferenceForm(forms.ModelForm): 'trigram': MultiSelectWidget, 'fulltext': MultiSelectWidget, } + + +class ShoppingPreferenceForm(forms.ModelForm): + prefix = 'shopping' + + class Meta: + model = UserPreference + + fields = ( + 'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand', + 'mealplan_autoinclude_related', 'default_delay', 'filter_to_supermarket' + ) + + help_texts = { + 'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'), + 'shopping_auto_sync': _( + 'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501 + '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.'), + 'default_delay': _('Default number of hours to delay a shopping list entry.'), + 'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'), + } + labels = { + 'shopping_share': _('Share Shopping List'), + 'shopping_auto_sync': _('Autosync'), + 'mealplan_autoadd_shopping': _('Auto Add Meal Plan'), + 'mealplan_autoexclude_onhand': _('Exclude On Hand'), + 'mealplan_autoinclude_related': _('Include Related'), + 'default_delay': _('Default Delay Hours'), + 'filter_to_supermarket': _('Filter to Supermarket'), + } + + widgets = { + 'shopping_share': MultiSelectWidget + } + + +class SpacePreferenceForm(forms.ModelForm): + prefix = 'space' + reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False, + help_text=_("Reset all food to inherit the fields configured.")) + + class Meta: + model = Space + + fields = ('food_inherit', 'reset_food_inherit',) + + help_texts = { + 'food_inherit': _('Fields on food that should be inherited by default.'), } + + widgets = { + 'food_inherit': MultiSelectWidget + } diff --git a/cookbook/helper/HelperFunctions.py b/cookbook/helper/HelperFunctions.py new file mode 100644 index 00000000..cf04c3e2 --- /dev/null +++ b/cookbook/helper/HelperFunctions.py @@ -0,0 +1,13 @@ +from django.db.models import Func + + +class Round(Func): + function = 'ROUND' + template = '%(function)s(%(expressions)s, 0)' + + +def str2bool(v): + if type(v) == bool: + return v + else: + return v.lower() in ("yes", "true", "1") diff --git a/cookbook/helper/permission_helper.py b/cookbook/helper/permission_helper.py index cb3f791d..ea6bcdb5 100644 --- a/cookbook/helper/permission_helper.py +++ b/cookbook/helper/permission_helper.py @@ -2,11 +2,9 @@ Source: https://djangosnippets.org/snippets/1703/ """ from django.conf import settings -from django.core.cache import caches - -from cookbook.models import ShareLink from django.contrib import messages from django.contrib.auth.decorators import user_passes_test +from django.core.cache import caches from django.core.exceptions import ValidationError from django.http import HttpResponseRedirect from django.urls import reverse, reverse_lazy @@ -14,6 +12,8 @@ from django.utils.translation import gettext as _ from rest_framework import permissions from rest_framework.permissions import SAFE_METHODS +from cookbook.models import ShareLink + def get_allowed_groups(groups_required): """ @@ -79,7 +79,11 @@ def is_object_shared(user, obj): # share checks for relevant objects if not user.is_authenticated: return False - return user in obj.get_shared() + if obj.__class__.__name__ == 'ShoppingListEntry': + # shopping lists are shared all or none and stored in user preferences + return obj.created_by in user.get_shopping_share() + else: + return user in obj.get_shared() def share_link_valid(recipe, share): diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index 850eb87d..ce87e0aa 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -8,24 +8,13 @@ from django.db.models.functions import Coalesce from django.utils import timezone, translation from cookbook.filters import RecipeFilter +from cookbook.helper.HelperFunctions import Round, str2bool from cookbook.helper.permission_helper import has_group_permission from cookbook.managers import DICTIONARY from cookbook.models import Food, Keyword, Recipe, SearchPreference, ViewLog from recipes import settings -class Round(Func): - function = 'ROUND' - template = '%(function)s(%(expressions)s, 0)' - - -def str2bool(v): - if type(v) == bool: - return v - else: - return v.lower() in ("yes", "true", "1") - - # TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected # TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering def search_recipes(request, queryset, params): @@ -49,7 +38,7 @@ def search_recipes(request, queryset, params): search_internal = str2bool(params.get('internal', False)) search_random = str2bool(params.get('random', False)) search_new = str2bool(params.get('new', False)) - search_last_viewed = int(params.get('last_viewed', 0)) + search_last_viewed = int(params.get('last_viewed', 0)) # not included in schema currently? orderby = [] # only sort by recent not otherwise filtering/sorting @@ -208,6 +197,7 @@ def search_recipes(request, queryset, params): return queryset +# TODO: This might be faster https://github.com/django-treebeard/django-treebeard/issues/115 def get_facet(qs=None, request=None, use_cache=True, hash_key=None): """ Gets an annotated list from a queryset. diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py new file mode 100644 index 00000000..26d590f8 --- /dev/null +++ b/cookbook/helper/shopping_helper.py @@ -0,0 +1,40 @@ +from datetime import timedelta + +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 django.utils.translation import gettext as _ + +from cookbook.helper.HelperFunctions import Round, str2bool +from cookbook.models import SupermarketCategoryRelation +from recipes import settings + + +def shopping_helper(qs, request): + supermarket = request.query_params.get('supermarket', None) + checked = request.query_params.get('checked', 'recent') + + supermarket_order = ['food__supermarket_category__name', 'food__name'] + + # TODO created either scheduled task or startup task to delete very old shopping list entries + # TODO create user preference to define 'very old' + + # qs = qs.annotate(supermarket_category=Coalesce(F('food__supermarket_category__name'), Value(_('Undefined')))) + # TODO add supermarket to API - order by category order + if supermarket: + supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category')) + qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999))) + supermarket_order = ['supermarket_order'] + supermarket_order + if checked in ['false', 0, '0']: + qs = qs.filter(checked=False) + elif checked in ['true', 1, '1']: + qs = qs.filter(checked=True) + elif checked in ['recent']: + today_start = timezone.now().replace(hour=0, minute=0, second=0) + # TODO make recent a user setting + week_ago = today_start - timedelta(days=7) + qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago)) + 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') diff --git a/cookbook/integration/integration.py b/cookbook/integration/integration.py index 2e5ac851..cecab4a2 100644 --- a/cookbook/integration/integration.py +++ b/cookbook/integration/integration.py @@ -3,7 +3,7 @@ import json import traceback import uuid from io import BytesIO, StringIO -from zipfile import ZipFile, BadZipFile +from zipfile import BadZipFile, ZipFile from bs4 import Tag from django.core.exceptions import ObjectDoesNotExist diff --git a/cookbook/migrations/0159_add_shoppinglistentry_fields.py b/cookbook/migrations/0159_add_shoppinglistentry_fields.py new file mode 100644 index 00000000..9df880d0 --- /dev/null +++ b/cookbook/migrations/0159_add_shoppinglistentry_fields.py @@ -0,0 +1,149 @@ +# Generated by Django 3.2.7 on 2021-10-01 20:52 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models +from django_scopes import scopes_disabled + +from cookbook.models import PermissionModelMixin, ShoppingListEntry + + +def copy_values_to_sle(apps, schema_editor): + with scopes_disabled(): + entries = ShoppingListEntry.objects.all() + for entry in entries: + if entry.shoppinglist_set.first(): + entry.created_by = entry.shoppinglist_set.first().created_by + entry.space = entry.shoppinglist_set.first().space + if entries: + ShoppingListEntry.objects.bulk_update(entries, ["created_by", "space", ]) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cookbook', '0158_userpreference_use_kj'), + ] + + operations = [ + migrations.AddField( + model_name='food', + name='on_hand', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='shoppinglistentry', + name='completed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='shoppinglistentry', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='shoppinglistentry', + name='created_by', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='auth.user'), + preserve_default=False, + ), + migrations.AddField( + model_name='userpreference', + name='shopping_share', + field=models.ManyToManyField(blank=True, related_name='shopping_share', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='shoppinglistentry', + name='space', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'), + preserve_default=False, + ), + migrations.AddField( + model_name='shoppinglistrecipe', + name='mealplan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.mealplan'), + ), + migrations.AddField( + model_name='shoppinglistrecipe', + name='name', + field=models.CharField(blank=True, default='', max_length=32), + ), + migrations.AddField( + model_name='shoppinglistentry', + name='ingredient', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.ingredient'), + ), + migrations.AlterField( + model_name='shoppinglistentry', + name='unit', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.unit'), + ), + migrations.AddField( + model_name='userpreference', + name='mealplan_autoadd_shopping', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userpreference', + name='mealplan_autoexclude_onhand', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='shoppinglistentry', + name='list_recipe', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='cookbook.shoppinglistrecipe'), + ), + migrations.CreateModel( + name='FoodInheritField', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('field', models.CharField(max_length=32, unique=True)), + ('name', models.CharField(max_length=64, unique=True)), + ], + bases=(models.Model, PermissionModelMixin), + ), + migrations.AddField( + model_name='food', + name='inherit', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userpreference', + name='mealplan_autoinclude_related', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='food', + name='ignore_inherit', + field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'), + ), + migrations.AddField( + model_name='space', + name='food_inherit', + field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'), + ), + migrations.AddField( + model_name='shoppinglistentry', + name='delay_until', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='userpreference', + name='default_delay', + field=models.DecimalField(decimal_places=4, default=4, max_digits=8), + ), + migrations.AddField( + model_name='userpreference', + name='filter_to_supermarket', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userpreference', + name='shopping_recent_days', + field=models.PositiveIntegerField(default=7), + ), + migrations.RunPython(copy_values_to_sle), + ] diff --git a/cookbook/migrations/0160_delete_shoppinglist_orphans.py b/cookbook/migrations/0160_delete_shoppinglist_orphans.py new file mode 100644 index 00000000..27fb0edb --- /dev/null +++ b/cookbook/migrations/0160_delete_shoppinglist_orphans.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.7 on 2021-10-01 22:34 + +import datetime +from datetime import timedelta + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +from django.utils import timezone +from django.utils.timezone import utc +from django_scopes import scopes_disabled + +from cookbook.models import FoodInheritField, ShoppingListEntry + + +def delete_orphaned_sle(apps, schema_editor): + with scopes_disabled(): + # shopping list entry is orphaned - delete it + ShoppingListEntry.objects.filter(shoppinglist=None).delete() + + +def create_inheritfields(apps, schema_editor): + FoodInheritField.objects.create(name='Supermarket Category', field='supermarket_category') + FoodInheritField.objects.create(name='Ignore Shopping', field='ignore_shopping') + FoodInheritField.objects.create(name='Diet', field='diet') + FoodInheritField.objects.create(name='Substitute', field='substitute') + FoodInheritField.objects.create(name='Substitute Children', field='substitute_children') + FoodInheritField.objects.create(name='Substitute Siblings', field='substitute_siblings') + + +def set_completed_at(apps, schema_editor): + today_start = timezone.now().replace(hour=0, minute=0, second=0) + # arbitrary - keeping all of the closed shopping list items out of the 'recent' view + month_ago = today_start - timedelta(days=30) + with scopes_disabled(): + ShoppingListEntry.objects.filter(checked=True).update(completed_at=month_ago) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cookbook', '0159_add_shoppinglistentry_fields'), + ] + + operations = [ + migrations.RunPython(delete_orphaned_sle), + migrations.RunPython(create_inheritfields), + migrations.RunPython(set_completed_at), + ] diff --git a/cookbook/migrations/0161_alter_shoppinglistentry_food.py b/cookbook/migrations/0161_alter_shoppinglistentry_food.py new file mode 100644 index 00000000..ab565354 --- /dev/null +++ b/cookbook/migrations/0161_alter_shoppinglistentry_food.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-11-03 23:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0160_delete_shoppinglist_orphans'), + ] + + operations = [ + migrations.AlterField( + model_name='shoppinglistentry', + name='food', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shopping_entries', to='cookbook.food'), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index aed927bd..d4d7d698 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -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) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 20c6fec2..24c333f4 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -4,6 +4,7 @@ from decimal import Decimal from gettext import gettext as _ from django.contrib.auth.models import User +from django.db import transaction from django.db.models import Avg, QuerySet, Sum from django.urls import reverse from django.utils import timezone @@ -11,12 +12,13 @@ from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer from rest_framework import serializers from rest_framework.exceptions import NotFound, ValidationError -from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, ImportLog, - Ingredient, Keyword, MealPlan, MealType, NutritionInformation, Recipe, - RecipeBook, RecipeBookEntry, RecipeImport, ShareLink, ShoppingList, - ShoppingListEntry, ShoppingListRecipe, Step, Storage, Supermarket, - SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit, - UserFile, UserPreference, ViewLog) +from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, + FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType, + NutritionInformation, Recipe, RecipeBook, RecipeBookEntry, + RecipeImport, ShareLink, ShoppingList, ShoppingListEntry, + ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory, + SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile, + UserPreference, ViewLog) from cookbook.templatetags.custom_tags import markdown @@ -61,7 +63,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): # probably not a tree pass if recipes.count() != 0: - return random.choice(recipes).image.url + return recipes.order_by('?')[:1][0].image.url else: return None @@ -78,7 +80,7 @@ class CustomDecimalField(serializers.Field): def to_representation(self, value): if not isinstance(value, Decimal): value = Decimal(value) - return round(value, 2).normalize() + return round(value, 3).normalize() def to_internal_value(self, data): if type(data) == int or type(data) == float: @@ -136,8 +138,27 @@ class UserNameSerializer(WritableNestedModelSerializer): fields = ('id', 'username') +class FoodInheritFieldSerializer(UniqueFieldsMixin): + + def create(self, validated_data): + # don't allow writing to FoodInheritField via API + return FoodInheritField.objects.get(**validated_data) + + def update(self, instance, validated_data): + # don't allow writing to FoodInheritField via API + return FoodInheritField.objects.get(**validated_data) + + class Meta: + model = FoodInheritField + fields = ['id', 'name', 'field', ] + + class UserPreferenceSerializer(serializers.ModelSerializer): - plan_share = UserNameSerializer(many=True, read_only=True) + # food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', read_only=True) + food_ignore_default = serializers.SerializerMethodField('get_ignore_default') + + def get_ignore_default(self, obj): + return FoodInheritFieldSerializer(Food.inherit_fields.difference(obj.space.food_inherit.all()), many=True).data def create(self, validated_data): if validated_data['user'] != self.context['request'].user: @@ -149,7 +170,8 @@ class UserPreferenceSerializer(serializers.ModelSerializer): fields = ( 'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'search_style', 'show_recent', 'plan_share', 'ingredient_decimals', - 'comments' + 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_ignore_default', 'default_delay', + 'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share' ) @@ -255,25 +277,11 @@ class KeywordLabelSerializer(serializers.ModelSerializer): class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin): label = serializers.SerializerMethodField('get_label') - # image = serializers.SerializerMethodField('get_image') - # numrecipe = serializers.SerializerMethodField('count_recipes') recipe_filter = 'keywords' def get_label(self, obj): return str(obj) - # def get_image(self, obj): - # recipes = obj.recipe_set.all().filter(space=obj.space).exclude(image__isnull=True).exclude(image__exact='') - # if recipes.count() == 0 and obj.has_children(): - # recipes = Recipe.objects.filter(keywords__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree - # if recipes.count() != 0: - # return random.choice(recipes).image.url - # else: - # return None - - # def count_recipes(self, obj): - # return obj.recipe_set.filter(space=self.context['request'].space).all().count() - def create(self, validated_data): # since multi select tags dont have id's # duplicate names might be routed to create @@ -285,27 +293,14 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin): class Meta: model = Keyword fields = ( - 'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at', - 'updated_at') - read_only_fields = ('id', 'numchild', 'parent', 'image') + 'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe') + read_only_fields = ('id', 'label', 'image', 'parent', 'numchild', 'numrecipe') class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin): - # image = serializers.SerializerMethodField('get_image') - # numrecipe = serializers.SerializerMethodField('count_recipes') + recipe_filter = 'steps__ingredients__unit' - # def get_image(self, obj): - # recipes = Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='') - - # if recipes.count() != 0: - # return random.choice(recipes).image.url - # else: - # return None - - # def count_recipes(self, obj): - # return Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).count() - def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() validated_data['space'] = self.context['request'].space @@ -369,27 +364,13 @@ class RecipeSimpleSerializer(serializers.ModelSerializer): class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin): supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) recipe = RecipeSimpleSerializer(allow_null=True, required=False) - # image = serializers.SerializerMethodField('get_image') - # numrecipe = serializers.SerializerMethodField('count_recipes') + shopping = serializers.SerializerMethodField('get_shopping_status') + ignore_inherit = FoodInheritFieldSerializer(many=True) + recipe_filter = 'steps__ingredients__food' - # def get_image(self, obj): - # if obj.recipe and obj.space == obj.recipe.space: - # if obj.recipe.image and obj.recipe.image != '': - # return obj.recipe.image.url - # # if food is not also a recipe, look for recipe images that use the food - # recipes = Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='') - # # if no recipes found - check whole tree - # if recipes.count() == 0 and obj.has_children(): - # recipes = Recipe.objects.filter(steps__ingredients__food__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='') - - # if recipes.count() != 0: - # return random.choice(recipes).image.url - # else: - # return None - - # def count_recipes(self, obj): - # return Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).count() + def get_shopping_status(self, obj): + return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0 def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() @@ -403,16 +384,17 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR return obj def update(self, instance, validated_data): - validated_data['name'] = validated_data['name'].strip() + if name := validated_data.get('name', None): + validated_data['name'] = name.strip() return super(FoodSerializer, self).update(instance, validated_data) class Meta: model = Food fields = ( - 'id', 'name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category', 'image', 'parent', - 'numchild', - 'numrecipe') - read_only_fields = ('id', 'numchild', 'parent', 'image') + 'id', 'name', 'description', 'shopping', 'recipe', 'ignore_shopping', 'supermarket_category', + 'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'inherit', 'ignore_inherit', + ) + read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') class IngredientSerializer(WritableNestedModelSerializer): @@ -559,6 +541,9 @@ class RecipeSerializer(RecipeBaseSerializer): validated_data['space'] = self.context['request'].space return super().create(validated_data) + def update(self, instance, validated_data): + return super().update(instance, validated_data) + class RecipeImageSerializer(WritableNestedModelSerializer): class Meta: @@ -628,7 +613,10 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): def create(self, validated_data): validated_data['created_by'] = self.context['request'].user - return super().create(validated_data) + 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']) + return mealplan class Meta: model = MealPlan @@ -640,34 +628,98 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): read_only_fields = ('created_by',) +# TODO deprecate class ShoppingListRecipeSerializer(serializers.ModelSerializer): + name = serializers.SerializerMethodField('get_name') # should this be done at the front end? recipe_name = serializers.ReadOnlyField(source='recipe.name') + mealplan_note = serializers.ReadOnlyField(source='mealplan.note') servings = CustomDecimalField() + def get_name(self, obj): + if not isinstance(value := obj.servings, Decimal): + value = Decimal(value) + value = value.quantize(Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero + return ( + obj.name + or getattr(obj.mealplan, 'title', None) + or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)]) + or obj.recipe.name + ) + f' ({value:.2g})' + + def update(self, instance, validated_data): + if 'servings' in validated_data: + ShoppingListEntry.list_from_recipe( + list_recipe=instance, + servings=validated_data['servings'], + created_by=self.context['request'].user, + space=self.context['request'].space + ) + return super().update(instance, validated_data) + class Meta: model = ShoppingListRecipe - fields = ('id', 'recipe', 'recipe_name', 'servings') + fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note') read_only_fields = ('id',) class ShoppingListEntrySerializer(WritableNestedModelSerializer): food = FoodSerializer(allow_null=True) unit = UnitSerializer(allow_null=True, required=False) + ingredient_note = serializers.ReadOnlyField(source='ingredient.note') + recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True) amount = CustomDecimalField() + created_by = UserNameSerializer(read_only=True) + completed_at = serializers.DateTimeField(allow_null=True) + + def get_fields(self, *args, **kwargs): + fields = super().get_fields(*args, **kwargs) + + # autosync values are only needed for frequent 'checked' value updating + if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))): + for f in list(set(fields) - set(['id', 'checked'])): + del fields[f] + return fields + + def run_validation(self, data): + if ( + data.get('checked', False) + and self.root.instance + and not self.root.instance.checked + ): + # if checked flips from false to true set completed datetime + data['completed_at'] = timezone.now() + elif not data.get('checked', False): + # if not checked set completed to None + data['completed_at'] = None + else: + # otherwise don't write anything + if 'completed_at' in data: + del data['completed_at'] + + return super().run_validation(data) + + def create(self, validated_data): + validated_data['space'] = self.context['request'].space + validated_data['created_by'] = self.context['request'].user + return super().create(validated_data) class Meta: model = ShoppingListEntry fields = ( - 'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked' + 'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan', + 'created_by', 'created_at', 'completed_at', 'delay_until' ) + read_only_fields = ('id', 'created_by', 'created_at',) +# TODO deprecate class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer): class Meta: model = ShoppingListEntry fields = ('id', 'checked') +# TODO deprecate class ShoppingListSerializer(WritableNestedModelSerializer): recipes = ShoppingListRecipeSerializer(many=True, allow_null=True) entries = ShoppingListEntrySerializer(many=True, allow_null=True) @@ -688,6 +740,7 @@ class ShoppingListSerializer(WritableNestedModelSerializer): read_only_fields = ('id', 'created_by',) +# TODO deprecate class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer): entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True) @@ -802,7 +855,7 @@ class FoodExportSerializer(FoodSerializer): class Meta: model = Food - fields = ('name', 'ignore_shopping', 'supermarket_category') + fields = ('name', 'ignore_shopping', 'supermarket_category', 'on_hand') class IngredientExportSerializer(WritableNestedModelSerializer): @@ -847,3 +900,24 @@ class RecipeExportSerializer(WritableNestedModelSerializer): validated_data['created_by'] = self.context['request'].user validated_data['space'] = self.context['request'].space return super().create(validated_data) + + +class RecipeShoppingUpdateSerializer(serializers.ModelSerializer): + list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Existing shopping list to update")) + ingredients = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_( + "List of ingredient IDs from the recipe to add, if not provided all ingredients will be added.")) + servings = serializers.IntegerField(default=1, write_only=True, allow_null=True, required=False, help_text=_("Providing a list_recipe ID and servings of 0 will delete that shopping list.")) + + class Meta: + model = Recipe + fields = ['id', 'list_recipe', 'ingredients', 'servings', ] + + +class FoodShoppingUpdateSerializer(serializers.ModelSerializer): + amount = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Amount of food to add to the shopping list")) + unit = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("ID of unit to use for the shopping list")) + delete = serializers.ChoiceField(choices=['true'], write_only=True, allow_null=True, allow_blank=True, help_text=_("When set to true will delete all food from active shopping lists.")) + + class Meta: + model = Recipe + fields = ['id', 'amount', 'unit', 'delete', ] diff --git a/cookbook/signals.py b/cookbook/signals.py index dc820c11..10ca5262 100644 --- a/cookbook/signals.py +++ b/cookbook/signals.py @@ -1,47 +1,80 @@ +from functools import wraps + from django.contrib.postgres.search import SearchVector from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import translation -from cookbook.models import Recipe, Step from cookbook.managers import DICTIONARY +from cookbook.models import Food, FoodInheritField, Recipe, Step + + +# wraps a signal with the ability to set 'skip_signal' to avoid creating recursive signals +def skip_signal(signal_func): + @wraps(signal_func) + def _decorator(sender, instance, **kwargs): + if not instance: + return None + if hasattr(instance, 'skip_signal'): + return None + return signal_func(sender, instance, **kwargs) + return _decorator # TODO there is probably a way to generalize this @receiver(post_save, sender=Recipe) +@skip_signal def update_recipe_search_vector(sender, instance=None, created=False, **kwargs): - if not instance: - return - - # needed to ensure search vector update doesn't trigger recursion - if hasattr(instance, '_dirty'): - return - language = DICTIONARY.get(translation.get_language(), 'simple') instance.name_search_vector = SearchVector('name__unaccent', weight='A', config=language) instance.desc_search_vector = SearchVector('description__unaccent', weight='C', config=language) - try: - instance._dirty = True + instance.skip_signal = True instance.save() finally: - del instance._dirty + del instance.skip_signal @receiver(post_save, sender=Step) +@skip_signal def update_step_search_vector(sender, instance=None, created=False, **kwargs): + language = DICTIONARY.get(translation.get_language(), 'simple') + instance.search_vector = SearchVector('instruction__unaccent', weight='B', config=language) + try: + instance.skip_signal = True + instance.save() + finally: + del instance.skip_signal + + +@receiver(post_save, sender=Food) +@skip_signal +def update_food_inheritance(sender, instance=None, created=False, **kwargs): if not instance: return - # needed to ensure search vector update doesn't trigger recursion - if hasattr(instance, '_dirty'): + inherit = Food.inherit_fields.difference(instance.ignore_inherit.all()) + # nothing to apply from parent and nothing to apply to children + if (not instance.inherit or not instance.parent or inherit.count() == 0) and instance.numchild == 0: return - language = DICTIONARY.get(translation.get_language(), 'simple') - instance.search_vector = SearchVector('instruction__unaccent', weight='B', config=language) + inherit = inherit.values_list('field', flat=True) + # apply changes from parent to instance for each inheritted field + if instance.inherit and instance.parent and inherit.count() > 0: + parent = instance.get_parent() + if 'ignore_shopping' in inherit: + instance.ignore_shopping = parent.ignore_shopping + # if supermarket_category is not set, do not cascade - if this becomes non-intuitive can change + if 'supermarket_category' in inherit and parent.supermarket_category: + instance.supermarket_category = parent.supermarket_category + try: + instance.skip_signal = True + instance.save() + finally: + del instance.skip_signal - try: - instance._dirty = True - instance.save() - finally: - del instance._dirty + # apply changes to direct children - depend on save signals for those objects to cascade inheritance down + instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='ignore_shopping').update(ignore_shopping=instance.ignore_shopping) + # 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) diff --git a/cookbook/templates/base.html b/cookbook/templates/base.html index b88570f4..6dd0943c 100644 --- a/cookbook/templates/base.html +++ b/cookbook/templates/base.html @@ -339,10 +339,10 @@ {% user_prefs request as prefs%} {{ prefs|json_script:'user_preference' }} - {% block script %} + {% endblock script %} - {% else %} - - {% endif %} - - - - {% render_bundle 'checklist_view' %} -{% endblock %} \ No newline at end of file diff --git a/cookbook/templates/generic/list_template.html b/cookbook/templates/generic/list_template.html index 673d9103..7d4be443 100644 --- a/cookbook/templates/generic/list_template.html +++ b/cookbook/templates/generic/list_template.html @@ -18,12 +18,23 @@ {% endif %}
{% trans 'User' %} | -{% trans 'Groups' %} | -{% trans 'Edit' %} | -
---|---|---|
- {{ u.user.username }} - | -- {{ u.user.groups.all |join:", " }} - | -
- {% if u.user != request.user %}
-
-
-
- {% trans 'Update' %}
-
-
- {% else %}
- {% trans 'You cannot edit yourself.' %}
- {% endif %}
- |
-
{% trans 'There are no members in your space yet!' %}
- {% endif %} -{% trans 'User' %} | +{% trans 'Groups' %} | +{% trans 'Edit' %} | +
---|---|---|
{{ u.user.username }} | +{{ u.user.groups.all |join:", " }} | +
+ {% if u.user != request.user %}
+
+
+
+ {% trans 'Update' %}
+
+
+ {% else %} {% trans 'You cannot edit yourself.' %} {% endif %}
+ |
+
{% trans 'There are no members in your space yet!' %}
+ {% endif %}{{ $t("Shopping_List_Empty") }}
+{{ $t("Shopping_List_Empty") }}
+ +{{ $t("Meal_Plan") }} | +{{ $t("Recipe") }} | +{{ $t("Servings") }} | ++ |
---|---|---|---|
{{ r.recipe_mealplan.name }} | +{{ r.recipe_mealplan.recipe_name }} | +
+ |
+ + + | +
-