From 5e4e203dfb4788b15b51a4dd1242542b0e77daf8 Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 21 Oct 2021 17:44:45 -0500 Subject: [PATCH] shopping line item --- cookbook/helper/shopping_helper.py | 41 +- .../0160_delete_shoppinglist_orphans.py | 11 + cookbook/serializer.py | 12 +- vue/src/apps/ChecklistView/ChecklistView.vue | 684 ++++++++++++------ vue/src/components/ShoppingLineItem.vue | 222 ++---- 5 files changed, 559 insertions(+), 411 deletions(-) diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index 731b8f35..bc28a001 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -1,11 +1,42 @@ -from django.db.models import Q +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 cookbook.models import UserPreference def shopping_helper(qs, request): - today_start = timezone.now().replace(hour=0, minute=0, second=0) - qs = qs.filter(Q(shoppinglist__created_by=request.user) | Q(shoppinglist__shared=request.user)).filter(shoppinglist__space=request.space) - qs = qs.filter(Q(checked=False) | Q(completed_at__gte=today_start)) - return qs + 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 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']: + 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', 'list_recipe__mealplan', 'list_recipe__recipe') diff --git a/cookbook/migrations/0160_delete_shoppinglist_orphans.py b/cookbook/migrations/0160_delete_shoppinglist_orphans.py index 349a673e..27fb0edb 100644 --- a/cookbook/migrations/0160_delete_shoppinglist_orphans.py +++ b/cookbook/migrations/0160_delete_shoppinglist_orphans.py @@ -1,10 +1,12 @@ # 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 @@ -26,6 +28,14 @@ def create_inheritfields(apps, schema_editor): 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 = [ @@ -36,4 +46,5 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(delete_orphaned_sle), migrations.RunPython(create_inheritfields), + migrations.RunPython(set_completed_at), ] diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 14a1cda4..f0600269 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -631,7 +631,7 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): 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.SerializerMethodField('get_note_markdown') + mealplan_note = serializers.ReadOnlyField(source='mealplan.note') servings = CustomDecimalField() def get_note_markdown(self, obj): @@ -639,7 +639,7 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): class Meta: model = ShoppingListRecipe - fields = ('id', 'recipe', 'mealplan', 'recipe_name', 'servings', 'mealplan_note') + fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note') read_only_fields = ('id',) @@ -679,6 +679,12 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): ): # 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 + del data['completed_at'] ############################################################ # temporary while old and new shopping lists are both in use @@ -707,7 +713,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): 'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan', 'created_by', 'created_at', 'completed_at' ) - read_only_fields = ('id', 'created_by', 'created_at',) + read_only_fields = ('id', 'list_recipe', 'created_by', 'created_at',) # TODO deprecate diff --git a/vue/src/apps/ChecklistView/ChecklistView.vue b/vue/src/apps/ChecklistView/ChecklistView.vue index 8990ce6e..fd86f5b1 100644 --- a/vue/src/apps/ChecklistView/ChecklistView.vue +++ b/vue/src/apps/ChecklistView/ChecklistView.vue @@ -1,255 +1,469 @@ diff --git a/vue/src/components/ShoppingLineItem.vue b/vue/src/components/ShoppingLineItem.vue index 30a3a5d1..f3fe29be 100644 --- a/vue/src/components/ShoppingLineItem.vue +++ b/vue/src/components/ShoppingLineItem.vue @@ -2,108 +2,68 @@ -
+
- -
-
-
{{ Object.entries(formatAmount)[0][1] }}   {{ Object.entries(formatAmount)[0][0] }}
-
{{ x[1] }}   {{ x[0] }}
-
-
- {{ formatFood }} {{ formatHint }} +
+
{{ formatAmount }}
+
{{ formatUnit }}
+ +
+ {{ formatFood }} ({{ formatHint }}) +
+
{{ formatNotes }}
{{ showDetails ? "Hide" : "Show" }} Details
-
-
-
-
- -
-
{{ formatOneMealPlan(e) }}
-
{{ formatOneCreatedBy(e) }}
-
-
-
{{ formatOneCompletedAt(e) }}
-
-
-
-
- - +
+
+
+
+
-
{{ formatOneAmount(e) }}
-
{{ formatOneUnit(e) }}
-
{{ formatOneFood(e) }}
- -
-
{{ n }}
-
+ + {{ e.amount }} - {{ e.unit }}- {{ e.recipe }}- {{ e.mealplan }}- {{ e.note }}- {{ e.unit }}
- -

- - -
@@ -111,10 +71,6 @@ import Vue from "vue" import { BootstrapVue } from "bootstrap-vue" import "bootstrap-vue/dist/bootstrap-vue.css" -import ContextMenu from "@/components/ContextMenu/ContextMenu" -import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem" -import { ApiMixin } from "@/utils/utils" -import RecipeCard from "./RecipeCard.vue" Vue.use(BootstrapVue) @@ -122,8 +78,8 @@ export default { // TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available // or i'm capturing it incorrectly name: "ShoppingLineItem", - mixins: [ApiMixin], - components: { RecipeCard, ContextMenu, ContextMenuItem }, + mixins: [], + components: {}, props: { entries: { type: Array, @@ -133,30 +89,17 @@ export default { data() { return { showDetails: false, - recipe: undefined, - servings: 1, } }, computed: { formatAmount: function() { - let amount = {} - this.entries.forEach((entry) => { - let unit = entry?.unit?.name ?? "----" - if (entry.amount) { - if (amount[unit]) { - amount[unit] += entry.amount - } else { - amount[unit] = entry.amount - } - } - }) - return amount + return this.entries[0].amount }, formatCategory: function() { - return this.formatOneCategory(this.entries[0]) || this.$t("Undefined") + return this.entries[0]?.food?.supermarket_category?.name ?? this.$t("Undefined") }, formatChecked: function() { - return this.entries.map((x) => x.checked).every((x) => x === true) + return false }, formatHint: function() { if (this.groupby == "recipe") { @@ -166,30 +109,24 @@ export default { } }, formatFood: function() { - return this.formatOneFood(this.entries[0]) + return this.entries[0]?.food?.name ?? this.$t("Undefined") }, formatUnit: function() { - return this.formatOneUnit(this.entries[0]) + return this.entries[0]?.unit?.name ?? this.$t("Undefined") }, formatRecipe: function() { - if (this.entries?.length == 1) { - return this.formatOneMealPlan(this.entries[0]) || "" + if (this.entries.length == 1) { + return this.entries[0]?.recipe_mealplan?.name ?? this.$t("Undefined") } else { - let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name) - return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ") + return [this.entries[0]?.recipe_mealplan?.name ?? this.$t("Undefined"), this.$t("CountMore", { count: this.entries.length - 1 })].join(" ") } }, formatNotes: function() { - if (this.entries?.length == 1) { - return this.formatOneNote(this.entries[0]) || "" - } - return "" + return [this.entries[0]?.recipe_mealplan?.mealplan_note, this.entries?.ingredient_note].filter(String).join("\n") }, }, watch: {}, - mounted() { - this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0 - }, + mounted() {}, methods: { // this.genericAPI inherited from ApiMixin @@ -199,57 +136,15 @@ export default { } return Intl.DateTimeFormat(window.navigator.language, { dateStyle: "short", timeStyle: "short" }).format(Date.parse(datetime)) }, - formatOneAmount: function(item) { - return item?.amount ?? 1 - }, - formatOneUnit: function(item) { - return item?.unit?.name ?? "" - }, - 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 - }, - formatOneChecked: function(item) { - return item.checked - }, - formatOneMealPlan: function(item) { - return item?.recipe_mealplan?.name - }, - formatOneRecipe: function(item) { - return item?.recipe_mealplan?.recipe_name - }, - formatOneNote: function(item) { - if (!item) { - item = this.entries[0] - } - return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String) - }, - formatOneCreatedBy: function(item) { - return [item?.created_by.username, "@", this.formatDate(item.created_at)].join(" ") - }, - openRecipeCard: function(e, item) { - this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, { id: item.recipe_mealplan.recipe }).then((result) => { - let recipe = result.data - recipe.steps = undefined - this.recipe = true - 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 }) - } + 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() }, }, } @@ -257,13 +152,4 @@ export default { - +