diff --git a/cookbook/forms.py b/cookbook/forms.py index 78f29637..063f79b0 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -479,8 +479,7 @@ class ShoppingPreferenceForm(forms.ModelForm): model = UserPreference fields = ( - 'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand', - 'mealplan_autoinclude_related', 'default_delay', 'filter_to_supermarket' + 'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand', 'default_delay' ) help_texts = { @@ -491,18 +490,14 @@ class ShoppingPreferenceForm(forms.ModelForm): ), '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 = { diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index bc28a001..9ea8a611 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -23,11 +23,6 @@ def shopping_helper(qs, request): 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 settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']: - # qs = qs.annotate(recipe_notes=ArrayAgg('list_recipe__recipe__steps__ingredients__note', filter=Q(list_recipe__recipe__steps__ingredients__food=F('food_id')))) - # qs = qs.annotate(meal_notes=ArrayAgg('list_recipe__mealplan__note', distinct=True, filter=Q(list_recipe__mealplan__note__isnull=False))) - # else: - # pass # ignore adding notes when running sqlite? or do some ugly contruction? if checked in ['false', 0, '0']: qs = qs.filter(checked=False) elif checked in ['true', 1, '1']: @@ -39,4 +34,4 @@ def shopping_helper(qs, request): 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', 'list_recipe__mealplan', 'list_recipe__recipe') + 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/models.py b/cookbook/models.py index f64bfff3..84907cfa 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -17,6 +17,7 @@ from django.db import IntegrityError, models from django.db.models import Index, ProtectedError, Q, Subquery from django.db.models.fields.related import ManyToManyField from django.db.models.functions import Substr +from django.db.transaction import atomic from django.utils import timezone from django.utils.translation import gettext as _ from django_prometheus.models import ExportModelOperationsMixin @@ -328,6 +329,7 @@ class UserPreference(models.Model, PermissionModelMixin): mealplan_autoadd_shopping = models.BooleanField(default=False) mealplan_autoexclude_onhand = models.BooleanField(default=True) mealplan_autoinclude_related = models.BooleanField(default=True) + default_delay = models.IntegerField(default=4) created_at = models.DateTimeField(auto_now_add=True) space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True) @@ -823,7 +825,7 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod def get_owner(self): try: - return getattr(self.entries.first(), 'created_by', None) or getattr(self.shoppinglist_set.first(), 'created_by', None) + return self.entries.first().created_by or self.shoppinglist_set.first().created_by except AttributeError: return None @@ -839,11 +841,13 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model 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) space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space') - @ classmethod + @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 @@ -853,22 +857,51 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model :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 associated recipes - try: - r = recipe or mealplan.recipe - except AttributeError: + # 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) + 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")) - servings = servings or getattr(mealplan, 'servings', 1.0) - if ingredients: + 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) - list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings) + 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, diff --git a/cookbook/serializer.py b/cookbook/serializer.py index f0600269..b5049d6c 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -162,7 +162,7 @@ class UserPreferenceSerializer(serializers.ModelSerializer): fields = ( 'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'search_style', 'show_recent', 'plan_share', 'ingredient_decimals', - 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_ignore_default' + 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_ignore_default', 'default_delay' ) @@ -392,7 +392,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR model = Food fields = ( 'id', 'name', 'description', 'shopping', 'recipe', 'ignore_shopping', 'supermarket_category', - 'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'inherit', 'ignore_inherit' + 'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'inherit', 'ignore_inherit', ) read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') @@ -634,8 +634,26 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): mealplan_note = serializers.ReadOnlyField(source='mealplan.note') servings = CustomDecimalField() - def get_note_markdown(self, obj): - return obj.mealplan and markdown(obj.mealplan.note) + 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 @@ -649,7 +667,8 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): ingredient_note = serializers.ReadOnlyField(source='ingredient.note') recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True) amount = CustomDecimalField() - created_by = UserNameSerializer() + 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) @@ -684,7 +703,8 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): data['completed_at'] = None else: # otherwise don't write anything - del data['completed_at'] + if 'completed_at' in data: + del data['completed_at'] ############################################################ # temporary while old and new shopping lists are both in use @@ -711,9 +731,9 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): model = ShoppingListEntry fields = ( 'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan', - 'created_by', 'created_at', 'completed_at' + 'created_by', 'created_at', 'completed_at', 'delay_until' ) - read_only_fields = ('id', 'list_recipe', 'created_by', 'created_at',) + read_only_fields = ('id', 'created_by', 'created_at',) # TODO deprecate diff --git a/cookbook/views/views.py b/cookbook/views/views.py index 0eb2a485..ab526747 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -385,7 +385,6 @@ def user_settings(request): up.mealplan_autoexclude_onhand = shopping_form.cleaned_data['mealplan_autoexclude_onhand'] up.mealplan_autoinclude_related = shopping_form.cleaned_data['mealplan_autoinclude_related'] up.shopping_auto_sync = shopping_form.cleaned_data['shopping_auto_sync'] - up.filter_to_supermarket = shopping_form.cleaned_data['filter_to_supermarket'] up.default_delay = shopping_form.cleaned_data['default_delay'] if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL: up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL diff --git a/vue/src/apps/ChecklistView/ChecklistView.vue b/vue/src/apps/ChecklistView/ChecklistView.vue deleted file mode 100644 index 0db80ec2..00000000 --- a/vue/src/apps/ChecklistView/ChecklistView.vue +++ /dev/null @@ -1,460 +0,0 @@ - - - - - - - diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue index 3e93d30b..ba764994 100644 --- a/vue/src/apps/ShoppingListView/ShoppingListView.vue +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -1,6 +1,8 @@ @@ -124,6 +131,7 @@ export default { return { showDetails: false, recipe: undefined, + servings: 1, } }, computed: { @@ -134,7 +142,7 @@ export default { return this.formatOneCategory(this.entries[0]) || this.$t("Undefined") }, formatChecked: function() { - return false + return this.entries.map((x) => x.checked).every((x) => x === true) }, formatHint: function() { if (this.groupby == "recipe") { @@ -165,7 +173,9 @@ export default { }, }, watch: {}, - mounted() {}, + mounted() { + this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0 + }, methods: { // this.genericAPI inherited from ApiMixin @@ -175,16 +185,6 @@ export default { } return Intl.DateTimeFormat(window.navigator.language, { dateStyle: "short", timeStyle: "short" }).format(Date.parse(datetime)) }, - checkboxChanged: function() { - console.log("click!") - // item.checked = !item.checked - // if (item.checked) { - // item.completed_at = new Date().toISOString() - // } - - // this.saveThis(item, false) - // this.$refs.table.refresh() - }, formatOneAmount: function(item) { return item?.amount ?? 1 }, @@ -194,6 +194,12 @@ export default { formatOneCategory: function(item) { return item?.food?.supermarket_category?.name }, + formatOneCompletedAt: function(item) { + if (!item.completed_at) { + return "" + } + return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ") + }, formatOneFood: function(item) { return item.food.name }, @@ -223,6 +229,14 @@ export default { this.$refs.recipe_card.open(e, recipe) }) }, + updateChecked: function(e, item) { + if (!item) { + let update = { entries: this.entries.map((x) => x.id), checked: !this.formatChecked } + this.$emit("update-checkbox", update) + } else { + this.$emit("update-checkbox", { id: item.id, checked: !item.checked }) + } + }, }, } diff --git a/vue/vue.config.js b/vue/vue.config.js index 25c539f4..90cdf729 100644 --- a/vue/vue.config.js +++ b/vue/vue.config.js @@ -91,9 +91,9 @@ module.exports = { }, // TODO make this conditional on .env DEBUG = FALSE config.optimization.minimize(false) - ); + ) - config.plugin('BundleTracker').use(BundleTracker, [{relativePath: true, path: '../vue/'}]); + config.plugin("BundleTracker").use(BundleTracker, [{ relativePath: true, path: "../vue/" }]) config.resolve.alias.set("__STATIC__", "static")