From 3516505dd1929401fc8c941a7336a17996da160c Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 16 Dec 2021 09:08:32 -0600 Subject: [PATCH 01/91] fix bug in get_facet_api --- cookbook/static/django_js_reverse/reverse.js | 26 ++-- cookbook/views/api.py | 8 +- .../RecipeSearchView/RecipeSearchView.vue | 67 ++------- vue/vue.config.js | 133 +++++++++--------- 4 files changed, 92 insertions(+), 142 deletions(-) diff --git a/cookbook/static/django_js_reverse/reverse.js b/cookbook/static/django_js_reverse/reverse.js index 105335cb8..af9a0e1df 100644 --- a/cookbook/static/django_js_reverse/reverse.js +++ b/cookbook/static/django_js_reverse/reverse.js @@ -1,14 +1,14 @@ -this.Urls=(function(){"use strict";var data={"urls":[["account_change_password",[["accounts/password/change/",[]]]],["account_confirm_email",[["accounts/confirm-email/%(key)s/",["key"]]]],["account_email",[["accounts/email/",[]]]],["account_email_verification_sent",[["accounts/confirm-email/",[]]]],["account_inactive",[["accounts/inactive/",[]]]],["account_login",[["accounts/login/",[]]]],["account_logout",[["accounts/logout/",[]]]],["account_reset_password",[["accounts/password/reset/",[]]]],["account_reset_password_done",[["accounts/password/reset/done/",[]]]],["account_reset_password_from_key",[["accounts/password/reset/key/%(uidb36)s-%(key)s/",["uidb36","key"]]]],["account_reset_password_from_key_done",[["accounts/password/reset/key/done/",[]]]],["account_set_password",[["accounts/password/set/",[]]]],["account_signup",[["accounts/signup/",[]]]],["admin:account_emailaddress_add",[["admin/account/emailaddress/add/",[]]]],["admin:account_emailaddress_autocomplete",[["admin/account/emailaddress/autocomplete/",[]]]],["admin:account_emailaddress_change",[["admin/account/emailaddress/%(object_id)s/change/",["object_id"]]]],["admin:account_emailaddress_changelist",[["admin/account/emailaddress/",[]]]],["admin:account_emailaddress_delete",[["admin/account/emailaddress/%(object_id)s/delete/",["object_id"]]]],["admin:account_emailaddress_history",[["admin/account/emailaddress/%(object_id)s/history/",["object_id"]]]],["admin:app_list",[["admin/%(app_label)s/",["app_label"]]]],["admin:auth_group_add",[["admin/auth/group/add/",[]]]],["admin:auth_group_autocomplete",[["admin/auth/group/autocomplete/",[]]]],["admin:auth_group_change",[["admin/auth/group/%(object_id)s/change/",["object_id"]]]],["admin:auth_group_changelist",[["admin/auth/group/",[]]]],["admin:auth_group_delete",[["admin/auth/group/%(object_id)s/delete/",["object_id"]]]],["admin:auth_group_history",[["admin/auth/group/%(object_id)s/history/",["object_id"]]]],["admin:auth_user_add",[["admin/auth/user/add/",[]]]],["admin:auth_user_autocomplete",[["admin/auth/user/autocomplete/",[]]]],["admin:auth_user_change",[["admin/auth/user/%(object_id)s/change/",["object_id"]]]],["admin:auth_user_changelist",[["admin/auth/user/",[]]]],["admin:auth_user_delete",[["admin/auth/user/%(object_id)s/delete/",["object_id"]]]],["admin:auth_user_history",[["admin/auth/user/%(object_id)s/history/",["object_id"]]]],["admin:auth_user_password_change",[["admin/auth/user/%(id)s/password/",["id"]]]],["admin:authtoken_tokenproxy_add",[["admin/authtoken/tokenproxy/add/",[]]]],["admin:authtoken_tokenproxy_autocomplete",[["admin/authtoken/tokenproxy/autocomplete/",[]]]],["admin:authtoken_tokenproxy_change",[["admin/authtoken/tokenproxy/%(object_id)s/change/",["object_id"]]]],["admin:authtoken_tokenproxy_changelist",[["admin/authtoken/tokenproxy/",[]]]],["admin:authtoken_tokenproxy_delete",[["admin/authtoken/tokenproxy/%(object_id)s/delete/",["object_id"]]]],["admin:authtoken_tokenproxy_history",[["admin/authtoken/tokenproxy/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_comment_add",[["admin/cookbook/comment/add/",[]]]],["admin:cookbook_comment_autocomplete",[["admin/cookbook/comment/autocomplete/",[]]]],["admin:cookbook_comment_change",[["admin/cookbook/comment/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_comment_changelist",[["admin/cookbook/comment/",[]]]],["admin:cookbook_comment_delete",[["admin/cookbook/comment/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_comment_history",[["admin/cookbook/comment/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_cooklog_add",[["admin/cookbook/cooklog/add/",[]]]],["admin:cookbook_cooklog_autocomplete",[["admin/cookbook/cooklog/autocomplete/",[]]]],["admin:cookbook_cooklog_change",[["admin/cookbook/cooklog/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_cooklog_changelist",[["admin/cookbook/cooklog/",[]]]],["admin:cookbook_cooklog_delete",[["admin/cookbook/cooklog/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_cooklog_history",[["admin/cookbook/cooklog/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_food_add",[["admin/cookbook/food/add/",[]]]],["admin:cookbook_food_autocomplete",[["admin/cookbook/food/autocomplete/",[]]]],["admin:cookbook_food_change",[["admin/cookbook/food/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_food_changelist",[["admin/cookbook/food/",[]]]],["admin:cookbook_food_delete",[["admin/cookbook/food/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_food_history",[["admin/cookbook/food/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_ingredient_add",[["admin/cookbook/ingredient/add/",[]]]],["admin:cookbook_ingredient_autocomplete",[["admin/cookbook/ingredient/autocomplete/",[]]]],["admin:cookbook_ingredient_change",[["admin/cookbook/ingredient/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_ingredient_changelist",[["admin/cookbook/ingredient/",[]]]],["admin:cookbook_ingredient_delete",[["admin/cookbook/ingredient/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_ingredient_history",[["admin/cookbook/ingredient/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_invitelink_add",[["admin/cookbook/invitelink/add/",[]]]],["admin:cookbook_invitelink_autocomplete",[["admin/cookbook/invitelink/autocomplete/",[]]]],["admin:cookbook_invitelink_change",[["admin/cookbook/invitelink/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_invitelink_changelist",[["admin/cookbook/invitelink/",[]]]],["admin:cookbook_invitelink_delete",[["admin/cookbook/invitelink/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_invitelink_history",[["admin/cookbook/invitelink/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_keyword_add",[["admin/cookbook/keyword/add/",[]]]],["admin:cookbook_keyword_autocomplete",[["admin/cookbook/keyword/autocomplete/",[]]]],["admin:cookbook_keyword_change",[["admin/cookbook/keyword/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_keyword_changelist",[["admin/cookbook/keyword/",[]]]],["admin:cookbook_keyword_delete",[["admin/cookbook/keyword/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_keyword_history",[["admin/cookbook/keyword/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_mealplan_add",[["admin/cookbook/mealplan/add/",[]]]],["admin:cookbook_mealplan_autocomplete",[["admin/cookbook/mealplan/autocomplete/",[]]]],["admin:cookbook_mealplan_change",[["admin/cookbook/mealplan/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_mealplan_changelist",[["admin/cookbook/mealplan/",[]]]],["admin:cookbook_mealplan_delete",[["admin/cookbook/mealplan/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_mealplan_history",[["admin/cookbook/mealplan/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_mealtype_add",[["admin/cookbook/mealtype/add/",[]]]],["admin:cookbook_mealtype_autocomplete",[["admin/cookbook/mealtype/autocomplete/",[]]]],["admin:cookbook_mealtype_change",[["admin/cookbook/mealtype/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_mealtype_changelist",[["admin/cookbook/mealtype/",[]]]],["admin:cookbook_mealtype_delete",[["admin/cookbook/mealtype/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_mealtype_history",[["admin/cookbook/mealtype/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_nutritioninformation_add",[["admin/cookbook/nutritioninformation/add/",[]]]],["admin:cookbook_nutritioninformation_autocomplete",[["admin/cookbook/nutritioninformation/autocomplete/",[]]]],["admin:cookbook_nutritioninformation_change",[["admin/cookbook/nutritioninformation/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_nutritioninformation_changelist",[["admin/cookbook/nutritioninformation/",[]]]],["admin:cookbook_nutritioninformation_delete",[["admin/cookbook/nutritioninformation/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_nutritioninformation_history",[["admin/cookbook/nutritioninformation/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_recipe_add",[["admin/cookbook/recipe/add/",[]]]],["admin:cookbook_recipe_autocomplete",[["admin/cookbook/recipe/autocomplete/",[]]]],["admin:cookbook_recipe_change",[["admin/cookbook/recipe/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_recipe_changelist",[["admin/cookbook/recipe/",[]]]],["admin:cookbook_recipe_delete",[["admin/cookbook/recipe/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_recipe_history",[["admin/cookbook/recipe/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_recipebook_add",[["admin/cookbook/recipebook/add/",[]]]],["admin:cookbook_recipebook_autocomplete",[["admin/cookbook/recipebook/autocomplete/",[]]]],["admin:cookbook_recipebook_change",[["admin/cookbook/recipebook/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_recipebook_changelist",[["admin/cookbook/recipebook/",[]]]],["admin:cookbook_recipebook_delete",[["admin/cookbook/recipebook/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_recipebook_history",[["admin/cookbook/recipebook/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_recipebookentry_add",[["admin/cookbook/recipebookentry/add/",[]]]],["admin:cookbook_recipebookentry_autocomplete",[["admin/cookbook/recipebookentry/autocomplete/",[]]]],["admin:cookbook_recipebookentry_change",[["admin/cookbook/recipebookentry/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_recipebookentry_changelist",[["admin/cookbook/recipebookentry/",[]]]],["admin:cookbook_recipebookentry_delete",[["admin/cookbook/recipebookentry/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_recipebookentry_history",[["admin/cookbook/recipebookentry/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_recipeimport_add",[["admin/cookbook/recipeimport/add/",[]]]],["admin:cookbook_recipeimport_autocomplete",[["admin/cookbook/recipeimport/autocomplete/",[]]]],["admin:cookbook_recipeimport_change",[["admin/cookbook/recipeimport/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_recipeimport_changelist",[["admin/cookbook/recipeimport/",[]]]],["admin:cookbook_recipeimport_delete",[["admin/cookbook/recipeimport/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_recipeimport_history",[["admin/cookbook/recipeimport/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_sharelink_add",[["admin/cookbook/sharelink/add/",[]]]],["admin:cookbook_sharelink_autocomplete",[["admin/cookbook/sharelink/autocomplete/",[]]]],["admin:cookbook_sharelink_change",[["admin/cookbook/sharelink/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_sharelink_changelist",[["admin/cookbook/sharelink/",[]]]],["admin:cookbook_sharelink_delete",[["admin/cookbook/sharelink/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_sharelink_history",[["admin/cookbook/sharelink/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_shoppinglist_add",[["admin/cookbook/shoppinglist/add/",[]]]],["admin:cookbook_shoppinglist_autocomplete",[["admin/cookbook/shoppinglist/autocomplete/",[]]]],["admin:cookbook_shoppinglist_change",[["admin/cookbook/shoppinglist/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_shoppinglist_changelist",[["admin/cookbook/shoppinglist/",[]]]],["admin:cookbook_shoppinglist_delete",[["admin/cookbook/shoppinglist/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_shoppinglist_history",[["admin/cookbook/shoppinglist/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_shoppinglistentry_add",[["admin/cookbook/shoppinglistentry/add/",[]]]],["admin:cookbook_shoppinglistentry_autocomplete",[["admin/cookbook/shoppinglistentry/autocomplete/",[]]]],["admin:cookbook_shoppinglistentry_change",[["admin/cookbook/shoppinglistentry/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_shoppinglistentry_changelist",[["admin/cookbook/shoppinglistentry/",[]]]],["admin:cookbook_shoppinglistentry_delete",[["admin/cookbook/shoppinglistentry/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_shoppinglistentry_history",[["admin/cookbook/shoppinglistentry/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_shoppinglistrecipe_add",[["admin/cookbook/shoppinglistrecipe/add/",[]]]],["admin:cookbook_shoppinglistrecipe_autocomplete",[["admin/cookbook/shoppinglistrecipe/autocomplete/",[]]]],["admin:cookbook_shoppinglistrecipe_change",[["admin/cookbook/shoppinglistrecipe/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_shoppinglistrecipe_changelist",[["admin/cookbook/shoppinglistrecipe/",[]]]],["admin:cookbook_shoppinglistrecipe_delete",[["admin/cookbook/shoppinglistrecipe/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_shoppinglistrecipe_history",[["admin/cookbook/shoppinglistrecipe/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_space_add",[["admin/cookbook/space/add/",[]]]],["admin:cookbook_space_autocomplete",[["admin/cookbook/space/autocomplete/",[]]]],["admin:cookbook_space_change",[["admin/cookbook/space/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_space_changelist",[["admin/cookbook/space/",[]]]],["admin:cookbook_space_delete",[["admin/cookbook/space/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_space_history",[["admin/cookbook/space/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_step_add",[["admin/cookbook/step/add/",[]]]],["admin:cookbook_step_autocomplete",[["admin/cookbook/step/autocomplete/",[]]]],["admin:cookbook_step_change",[["admin/cookbook/step/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_step_changelist",[["admin/cookbook/step/",[]]]],["admin:cookbook_step_delete",[["admin/cookbook/step/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_step_history",[["admin/cookbook/step/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_storage_add",[["admin/cookbook/storage/add/",[]]]],["admin:cookbook_storage_autocomplete",[["admin/cookbook/storage/autocomplete/",[]]]],["admin:cookbook_storage_change",[["admin/cookbook/storage/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_storage_changelist",[["admin/cookbook/storage/",[]]]],["admin:cookbook_storage_delete",[["admin/cookbook/storage/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_storage_history",[["admin/cookbook/storage/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_supermarket_add",[["admin/cookbook/supermarket/add/",[]]]],["admin:cookbook_supermarket_autocomplete",[["admin/cookbook/supermarket/autocomplete/",[]]]],["admin:cookbook_supermarket_change",[["admin/cookbook/supermarket/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_supermarket_changelist",[["admin/cookbook/supermarket/",[]]]],["admin:cookbook_supermarket_delete",[["admin/cookbook/supermarket/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_supermarket_history",[["admin/cookbook/supermarket/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_supermarketcategory_add",[["admin/cookbook/supermarketcategory/add/",[]]]],["admin:cookbook_supermarketcategory_autocomplete",[["admin/cookbook/supermarketcategory/autocomplete/",[]]]],["admin:cookbook_supermarketcategory_change",[["admin/cookbook/supermarketcategory/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_supermarketcategory_changelist",[["admin/cookbook/supermarketcategory/",[]]]],["admin:cookbook_supermarketcategory_delete",[["admin/cookbook/supermarketcategory/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_supermarketcategory_history",[["admin/cookbook/supermarketcategory/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_sync_add",[["admin/cookbook/sync/add/",[]]]],["admin:cookbook_sync_autocomplete",[["admin/cookbook/sync/autocomplete/",[]]]],["admin:cookbook_sync_change",[["admin/cookbook/sync/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_sync_changelist",[["admin/cookbook/sync/",[]]]],["admin:cookbook_sync_delete",[["admin/cookbook/sync/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_sync_history",[["admin/cookbook/sync/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_synclog_add",[["admin/cookbook/synclog/add/",[]]]],["admin:cookbook_synclog_autocomplete",[["admin/cookbook/synclog/autocomplete/",[]]]],["admin:cookbook_synclog_change",[["admin/cookbook/synclog/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_synclog_changelist",[["admin/cookbook/synclog/",[]]]],["admin:cookbook_synclog_delete",[["admin/cookbook/synclog/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_synclog_history",[["admin/cookbook/synclog/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_unit_add",[["admin/cookbook/unit/add/",[]]]],["admin:cookbook_unit_autocomplete",[["admin/cookbook/unit/autocomplete/",[]]]],["admin:cookbook_unit_change",[["admin/cookbook/unit/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_unit_changelist",[["admin/cookbook/unit/",[]]]],["admin:cookbook_unit_delete",[["admin/cookbook/unit/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_unit_history",[["admin/cookbook/unit/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_userpreference_add",[["admin/cookbook/userpreference/add/",[]]]],["admin:cookbook_userpreference_autocomplete",[["admin/cookbook/userpreference/autocomplete/",[]]]],["admin:cookbook_userpreference_change",[["admin/cookbook/userpreference/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_userpreference_changelist",[["admin/cookbook/userpreference/",[]]]],["admin:cookbook_userpreference_delete",[["admin/cookbook/userpreference/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_userpreference_history",[["admin/cookbook/userpreference/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_viewlog_add",[["admin/cookbook/viewlog/add/",[]]]],["admin:cookbook_viewlog_autocomplete",[["admin/cookbook/viewlog/autocomplete/",[]]]],["admin:cookbook_viewlog_change",[["admin/cookbook/viewlog/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_viewlog_changelist",[["admin/cookbook/viewlog/",[]]]],["admin:cookbook_viewlog_delete",[["admin/cookbook/viewlog/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_viewlog_history",[["admin/cookbook/viewlog/%(object_id)s/history/",["object_id"]]]],["admin:index",[["admin/",[]]]],["admin:javascript-catalog",[["admin/cookbook/keyword/jsi18n/",[]]]],["admin:jsi18n",[["admin/jsi18n/",[]]]],["admin:login",[["admin/login/",[]]]],["admin:logout",[["admin/logout/",[]]]],["admin:password_change",[["admin/password_change/",[]]]],["admin:password_change_done",[["admin/password_change/done/",[]]]],["admin:sites_site_add",[["admin/sites/site/add/",[]]]],["admin:sites_site_autocomplete",[["admin/sites/site/autocomplete/",[]]]],["admin:sites_site_change",[["admin/sites/site/%(object_id)s/change/",["object_id"]]]],["admin:sites_site_changelist",[["admin/sites/site/",[]]]],["admin:sites_site_delete",[["admin/sites/site/%(object_id)s/delete/",["object_id"]]]],["admin:sites_site_history",[["admin/sites/site/%(object_id)s/history/",["object_id"]]]],["admin:socialaccount_socialaccount_add",[["admin/socialaccount/socialaccount/add/",[]]]],["admin:socialaccount_socialaccount_autocomplete",[["admin/socialaccount/socialaccount/autocomplete/",[]]]],["admin:socialaccount_socialaccount_change",[["admin/socialaccount/socialaccount/%(object_id)s/change/",["object_id"]]]],["admin:socialaccount_socialaccount_changelist",[["admin/socialaccount/socialaccount/",[]]]],["admin:socialaccount_socialaccount_delete",[["admin/socialaccount/socialaccount/%(object_id)s/delete/",["object_id"]]]],["admin:socialaccount_socialaccount_history",[["admin/socialaccount/socialaccount/%(object_id)s/history/",["object_id"]]]],["admin:socialaccount_socialapp_add",[["admin/socialaccount/socialapp/add/",[]]]],["admin:socialaccount_socialapp_autocomplete",[["admin/socialaccount/socialapp/autocomplete/",[]]]],["admin:socialaccount_socialapp_change",[["admin/socialaccount/socialapp/%(object_id)s/change/",["object_id"]]]],["admin:socialaccount_socialapp_changelist",[["admin/socialaccount/socialapp/",[]]]],["admin:socialaccount_socialapp_delete",[["admin/socialaccount/socialapp/%(object_id)s/delete/",["object_id"]]]],["admin:socialaccount_socialapp_history",[["admin/socialaccount/socialapp/%(object_id)s/history/",["object_id"]]]],["admin:socialaccount_socialtoken_add",[["admin/socialaccount/socialtoken/add/",[]]]],["admin:socialaccount_socialtoken_autocomplete",[["admin/socialaccount/socialtoken/autocomplete/",[]]]],["admin:socialaccount_socialtoken_change",[["admin/socialaccount/socialtoken/%(object_id)s/change/",["object_id"]]]],["admin:socialaccount_socialtoken_changelist",[["admin/socialaccount/socialtoken/",[]]]],["admin:socialaccount_socialtoken_delete",[["admin/socialaccount/socialtoken/%(object_id)s/delete/",["object_id"]]]],["admin:socialaccount_socialtoken_history",[["admin/socialaccount/socialtoken/%(object_id)s/history/",["object_id"]]]],["admin:view_on_site",[["admin/r/%(content_type_id)s/%(object_id)s/",["content_type_id","object_id"]]]],["api:api-root",[["api/.%(format)s",["format"]],["api/",[]]]],["api:cooklog-detail",[["api/cook-log/%(pk)s.%(format)s",["pk","format"]],["api/cook-log/%(pk)s/",["pk"]]]],["api:cooklog-list",[["api/cook-log.%(format)s",["format"]],["api/cook-log/",[]]]],["api:food-detail",[["api/food/%(pk)s.%(format)s",["pk","format"]],["api/food/%(pk)s/",["pk"]]]],["api:food-list",[["api/food.%(format)s",["format"]],["api/food/",[]]]],["api:ingredient-detail",[["api/ingredient/%(pk)s.%(format)s",["pk","format"]],["api/ingredient/%(pk)s/",["pk"]]]],["api:ingredient-list",[["api/ingredient.%(format)s",["format"]],["api/ingredient/",[]]]],["api:keyword-detail",[["api/keyword/%(pk)s.%(format)s",["pk","format"]],["api/keyword/%(pk)s/",["pk"]]]],["api:keyword-list",[["api/keyword.%(format)s",["format"]],["api/keyword/",[]]]],["api:mealplan-detail",[["api/meal-plan/%(pk)s.%(format)s",["pk","format"]],["api/meal-plan/%(pk)s/",["pk"]]]],["api:mealplan-list",[["api/meal-plan.%(format)s",["format"]],["api/meal-plan/",[]]]],["api:mealtype-detail",[["api/meal-type/%(pk)s.%(format)s",["pk","format"]],["api/meal-type/%(pk)s/",["pk"]]]],["api:mealtype-list",[["api/meal-type.%(format)s",["format"]],["api/meal-type/",[]]]],["api:recipe-detail",[["api/recipe/%(pk)s.%(format)s",["pk","format"]],["api/recipe/%(pk)s/",["pk"]]]],["api:recipe-image",[["api/recipe/%(pk)s/image.%(format)s",["pk","format"]],["api/recipe/%(pk)s/image/",["pk"]]]],["api:recipe-list",[["api/recipe.%(format)s",["format"]],["api/recipe/",[]]]],["api:recipebook-detail",[["api/recipe-book/%(pk)s.%(format)s",["pk","format"]],["api/recipe-book/%(pk)s/",["pk"]]]],["api:recipebook-list",[["api/recipe-book.%(format)s",["format"]],["api/recipe-book/",[]]]],["api:recipebookentry-detail",[["api/recipe-book-entry/%(pk)s.%(format)s",["pk","format"]],["api/recipe-book-entry/%(pk)s/",["pk"]]]],["api:recipebookentry-list",[["api/recipe-book-entry.%(format)s",["format"]],["api/recipe-book-entry/",[]]]],["api:shoppinglist-detail",[["api/shopping-list/%(pk)s.%(format)s",["pk","format"]],["api/shopping-list/%(pk)s/",["pk"]]]],["api:shoppinglist-list",[["api/shopping-list.%(format)s",["format"]],["api/shopping-list/",[]]]],["api:shoppinglistentry-detail",[["api/shopping-list-entry/%(pk)s.%(format)s",["pk","format"]],["api/shopping-list-entry/%(pk)s/",["pk"]]]],["api:shoppinglistentry-list",[["api/shopping-list-entry.%(format)s",["format"]],["api/shopping-list-entry/",[]]]],["api:shoppinglistrecipe-detail",[["api/shopping-list-recipe/%(pk)s.%(format)s",["pk","format"]],["api/shopping-list-recipe/%(pk)s/",["pk"]]]],["api:shoppinglistrecipe-list",[["api/shopping-list-recipe.%(format)s",["format"]],["api/shopping-list-recipe/",[]]]],["api:step-detail",[["api/step/%(pk)s.%(format)s",["pk","format"]],["api/step/%(pk)s/",["pk"]]]],["api:step-list",[["api/step.%(format)s",["format"]],["api/step/",[]]]],["api:storage-detail",[["api/storage/%(pk)s.%(format)s",["pk","format"]],["api/storage/%(pk)s/",["pk"]]]],["api:storage-list",[["api/storage.%(format)s",["format"]],["api/storage/",[]]]],["api:supermarket-detail",[["api/supermarket/%(pk)s.%(format)s",["pk","format"]],["api/supermarket/%(pk)s/",["pk"]]]],["api:supermarket-list",[["api/supermarket.%(format)s",["format"]],["api/supermarket/",[]]]],["api:sync-detail",[["api/sync/%(pk)s.%(format)s",["pk","format"]],["api/sync/%(pk)s/",["pk"]]]],["api:sync-list",[["api/sync.%(format)s",["format"]],["api/sync/",[]]]],["api:synclog-detail",[["api/sync-log/%(pk)s.%(format)s",["pk","format"]],["api/sync-log/%(pk)s/",["pk"]]]],["api:synclog-list",[["api/sync-log.%(format)s",["format"]],["api/sync-log/",[]]]],["api:unit-detail",[["api/unit/%(pk)s.%(format)s",["pk","format"]],["api/unit/%(pk)s/",["pk"]]]],["api:unit-list",[["api/unit.%(format)s",["format"]],["api/unit/",[]]]],["api:username-detail",[["api/user-name/%(pk)s.%(format)s",["pk","format"]],["api/user-name/%(pk)s/",["pk"]]]],["api:username-list",[["api/user-name.%(format)s",["format"]],["api/user-name/",[]]]],["api:userpreference-detail",[["api/user-preference/%(pk)s.%(format)s",["pk","format"]],["api/user-preference/%(pk)s/",["pk"]]]],["api:userpreference-list",[["api/user-preference.%(format)s",["format"]],["api/user-preference/",[]]]],["api:viewlog-detail",[["api/view-log/%(pk)s.%(format)s",["pk","format"]],["api/view-log/%(pk)s/",["pk"]]]],["api:viewlog-list",[["api/view-log.%(format)s",["format"]],["api/view-log/",[]]]],["api_backup",[["api/backup/",[]]]],["api_get_external_file_link",[["api/get_external_file_link/%(recipe_id)s/",["recipe_id"]]]],["api_get_plan_ical",[["api/plan-ical/%(from_date)s/%(to_date)s/",["from_date","to_date"]]]],["api_get_recipe_file",[["api/get_recipe_file/%(recipe_id)s/",["recipe_id"]]]],["api_ingredient_from_string",[["api/ingredient-from-string/",[]]]],["api_log_cooking",[["api/log_cooking/%(recipe_id)s/",["recipe_id"]]]],["api_recipe_from_url",[["api/recipe-from-url/",[]]]],["api_sync",[["api/sync_all/",[]]]],["dal_food",[["dal/food/",[]]]],["dal_keyword",[["dal/keyword/",[]]]],["dal_unit",[["dal/unit/",[]]]],["data_batch_edit",[["data/batch/edit",[]]]],["data_batch_import",[["data/batch/import",[]]]],["data_import_url",[["data/import/url",[]]]],["data_stats",[["data/statistics",[]]]],["data_sync",[["data/sync",[]]]],["data_sync_wait",[["data/sync/wait",[]]]],["delete_comment",[["delete/comment/%(pk)s/",["pk"]]]],["delete_invite_link",[["delete/invite-link/%(pk)s/",["pk"]]]],["delete_keyword",[["delete/keyword/%(pk)s/",["pk"]]]],["delete_meal_plan",[["delete/meal-plan/%(pk)s/",["pk"]]]],["delete_recipe",[["delete/recipe/%(pk)s/",["pk"]]]],["delete_recipe_book",[["delete/recipe-book/%(pk)s/",["pk"]]]],["delete_recipe_book_entry",[["delete/recipe-book-entry/%(pk)s/",["pk"]]]],["delete_recipe_import",[["delete/recipe-import/%(pk)s/",["pk"]]]],["delete_recipe_source",[["delete/recipe-source/%(pk)s/",["pk"]]]],["delete_storage",[["delete/storage/%(pk)s/",["pk"]]]],["delete_sync",[["delete/sync/%(pk)s/",["pk"]]]],["docs_api",[["docs/api/",[]]]],["docs_markdown",[["docs/markdown/",[]]]],["edit_comment",[["edit/comment/%(pk)s/",["pk"]]]],["edit_convert_recipe",[["edit/recipe/convert/%(pk)s/",["pk"]]]],["edit_external_recipe",[["edit/recipe/external/%(pk)s/",["pk"]]]],["edit_food",[["edit/food/%(pk)s/",["pk"]],["edit/ingredient/",[]]]],["edit_internal_recipe",[["edit/recipe/internal/%(pk)s/",["pk"]]]],["edit_keyword",[["edit/keyword/%(pk)s/",["pk"]]]],["edit_meal_plan",[["edit/meal-plan/%(pk)s/",["pk"]]]],["edit_recipe",[["edit/recipe/%(pk)s/",["pk"]]]],["edit_recipe_book",[["edit/recipe-book/%(pk)s/",["pk"]]]],["edit_storage",[["edit/storage/%(pk)s/",["pk"]]]],["edit_sync",[["edit/sync/%(pk)s/",["pk"]]]],["index",[["",[]]]],["javascript-catalog",[["jsi18n/",[]]]],["js_reverse",[["jsreverse.json",[]]]],["list_food",[["list/food/",[]]]],["list_invite_link",[["list/invite-link/",[]]]],["list_keyword",[["list/keyword/",[]]]],["list_recipe_import",[["list/recipe-import/",[]]]],["list_shopping_list",[["list/shopping-list/",[]]]],["list_storage",[["list/storage/",[]]]],["list_sync_log",[["list/sync-log/",[]]]],["move_keyword",[["move/keyword/%(child)s/%(parent)s",["child","parent"]]]],["new_invite_link",[["new/invite-link/",[]]]],["new_keyword",[["new/keyword/",[]]]],["new_meal_plan",[["new/meal-plan/",[]]]],["new_recipe",[["new/recipe/",[]]]],["new_recipe_book",[["new/recipe-book/",[]]]],["new_recipe_import",[["new/recipe-import/%(import_id)s/",["import_id"]]]],["new_share_link",[["new/share-link/%(pk)s/",["pk"]]]],["new_storage",[["new/storage/",[]]]],["openapi-schema",[["openapi",[]]]],["rest_framework:login",[["api-auth/login/",[]]]],["rest_framework:logout",[["api-auth/logout/",[]]]],["service_worker",[["service-worker.js",[]]]],["set_language",[["i18n/setlang/",[]]]],["socialaccount_connections",[["accounts/social/connections/",[]]]],["socialaccount_login_cancelled",[["accounts/social/login/cancelled/",[]]]],["socialaccount_login_error",[["accounts/social/login/error/",[]]]],["socialaccount_signup",[["accounts/social/signup/",[]]]],["view_books",[["books/",[]]]],["view_export",[["export/",[]]]],["view_history",[["history/",[]]]],["view_import",[["import/",[]]]],["view_no_group",[["no-group",[]]]],["view_offline",[["offline/",[]]]],["view_plan",[["plan/",[]]]],["view_plan_entry",[["plan/entry/%(pk)s",["pk"]]]],["view_recipe",[["view/recipe/%(pk)s/%(share)s",["pk","share"]],["view/recipe/%(pk)s",["pk"]]]],["view_search",[["search/",[]]]],["view_settings",[["settings/",[]]]],["view_setup",[["setup/",[]]]],["view_shopping",[["shopping/%(pk)s",["pk"]],["shopping/",[]]]],["view_shopping_latest",[["shopping/latest/",[]]]],["view_signup",[["signup/%(token)s",["token"]]]],["view_system",[["system/",[]]]],["view_test",[["test/",[]]]],["view_test2",[["test2/",[]]]],["web_manifest",[["manifest.json",[]]]]],"prefix":"/"};function factory(d){var url_patterns=d.urls;var url_prefix=d.prefix;var Urls={};var self_url_patterns={};var _get_url=function(url_pattern){return function(){var _arguments,index,url,url_arg,url_args,_i,_len,_ref,_ref_list,match_ref,provided_keys,build_kwargs;_arguments=arguments;_ref_list=self_url_patterns[url_pattern];if(arguments.length==1&&typeof(arguments[0])=="object"){var provided_keys_list=Object.keys(arguments[0]);provided_keys={};for(_i=0;_i
- + - + - + @@ -78,13 +69,7 @@ - + @@ -99,9 +84,7 @@
- {{ $t("Close") }} - + {{ $t("Close") }}
@@ -123,13 +106,7 @@ /> - + {{ $t("or") }} {{ $t("and") }} @@ -156,13 +133,7 @@ /> - + {{ $t("or") }} {{ $t("and") }} @@ -187,14 +158,7 @@ > - + {{ $t("or") }} {{ $t("and") }} @@ -242,14 +206,7 @@
@@ -258,8 +215,7 @@
- - +
@@ -389,7 +345,7 @@ export default { this.refreshData(false) }) this.$i18n.locale = window.CUSTOM_LOCALE - this.debug = localStorage.getItem("DEBUG") || false + this.debug = localStorage.getItem("DEBUG") == "True" || false }, watch: { settings: { @@ -420,6 +376,7 @@ export default { // this.genericAPI inherited from ApiMixin refreshData: function (random) { this.random_search = random + console.log(this.debug, typeof this.debug) let params = { query: this.settings.search_input, keywords: this.settings.search_keywords, diff --git a/vue/vue.config.js b/vue/vue.config.js index beb599d1a..cfbf9872b 100644 --- a/vue/vue.config.js +++ b/vue/vue.config.js @@ -1,45 +1,45 @@ -const BundleTracker = require("webpack-bundle-tracker"); +const BundleTracker = require("webpack-bundle-tracker") const pages = { - 'recipe_search_view': { - entry: './src/apps/RecipeSearchView/main.js', - chunks: ['chunk-vendors'] + recipe_search_view: { + entry: "./src/apps/RecipeSearchView/main.js", + chunks: ["chunk-vendors"], }, - 'recipe_view': { - entry: './src/apps/RecipeView/main.js', - chunks: ['chunk-vendors'] + recipe_view: { + entry: "./src/apps/RecipeView/main.js", + chunks: ["chunk-vendors"], }, - 'offline_view': { - entry: './src/apps/OfflineView/main.js', - chunks: ['chunk-vendors'] + offline_view: { + entry: "./src/apps/OfflineView/main.js", + chunks: ["chunk-vendors"], }, - 'import_response_view': { - entry: './src/apps/ImportResponseView/main.js', - chunks: ['chunk-vendors'] + import_response_view: { + entry: "./src/apps/ImportResponseView/main.js", + chunks: ["chunk-vendors"], }, - 'supermarket_view': { - entry: './src/apps/SupermarketView/main.js', - chunks: ['chunk-vendors'] + supermarket_view: { + entry: "./src/apps/SupermarketView/main.js", + chunks: ["chunk-vendors"], }, - 'model_list_view': { - entry: './src/apps/ModelListView/main.js', - chunks: ['chunk-vendors'] + model_list_view: { + entry: "./src/apps/ModelListView/main.js", + chunks: ["chunk-vendors"], }, - 'edit_internal_recipe': { - entry: './src/apps/RecipeEditView/main.js', - chunks: ['chunk-vendors'] + edit_internal_recipe: { + entry: "./src/apps/RecipeEditView/main.js", + chunks: ["chunk-vendors"], }, - 'cookbook_view': { - entry: './src/apps/CookbookView/main.js', - chunks: ['chunk-vendors'] + cookbook_view: { + entry: "./src/apps/CookbookView/main.js", + chunks: ["chunk-vendors"], }, - 'meal_plan_view': { - entry: './src/apps/MealPlanView/main.js', - chunks: ['chunk-vendors'] + meal_plan_view: { + entry: "./src/apps/MealPlanView/main.js", + chunks: ["chunk-vendors"], }, - 'checklist_view': { - entry: './src/apps/ChecklistView/main.js', - chunks: ['chunk-vendors'] + checklist_view: { + entry: "./src/apps/ChecklistView/main.js", + chunks: ["chunk-vendors"], }, } @@ -47,54 +47,51 @@ module.exports = { pages: pages, filenameHashing: false, productionSourceMap: false, - publicPath: process.env.NODE_ENV === 'production' - ? '' - : 'http://localhost:8080/', - outputDir: '../cookbook/static/vue/', + publicPath: process.env.NODE_ENV === "production" ? "" : "http://localhost:8080/", + outputDir: "../cookbook/static/vue/", runtimeCompiler: true, pwa: { - name: 'Recipes', - themeColor: '#4DBA87', - msTileColor: '#000000', - appleMobileWebAppCapable: 'yes', - appleMobileWebAppStatusBarStyle: 'black', + name: "Recipes", + themeColor: "#4DBA87", + msTileColor: "#000000", + appleMobileWebAppCapable: "yes", + appleMobileWebAppStatusBarStyle: "black", - - workboxPluginMode: 'InjectManifest', + workboxPluginMode: "InjectManifest", workboxOptions: { - swSrc: './src/sw.js', - swDest: '../../templates/sw.js', + swSrc: "./src/sw.js", + swDest: "../../templates/sw.js", manifestTransforms: [ - originalManifest => { - const result = originalManifest.map(entry => new Object({url: 'static/vue/' + entry.url})) - return {manifest: result, warnings: []}; - } + (originalManifest) => { + const result = originalManifest.map((entry) => new Object({ url: "static/vue/" + entry.url })) + return { manifest: result, warnings: [] } + }, ], - } + }, }, pluginOptions: { i18n: { - locale: 'en', - fallbackLocale: 'en', - localeDir: 'locales', - enableInSFC: true - } + locale: "en", + fallbackLocale: "en", + localeDir: "locales", + enableInSFC: true, + }, }, - chainWebpack: config => { - - config.optimization.splitChunks({ + chainWebpack: (config) => { + config.optimization.splitChunks( + { cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: "chunk-vendors", chunks: "all", - priority: 1 + priority: 1, }, }, }, // TODO make this conditional on .env DEBUG = FALSE config.optimization.minimize(true) - ); + ) //TODO somehow remov them as they are also added to the manifest config of the service worker /* @@ -105,19 +102,17 @@ module.exports = { }) */ - config.plugin('BundleTracker').use(BundleTracker, [{relativePath: true, path: '../vue/'}]); + config.plugin("BundleTracker").use(BundleTracker, [{ relativePath: true, path: "../vue/" }]) - config.resolve.alias - .set('__STATIC__', 'static') + config.resolve.alias.set("__STATIC__", "static") config.devServer - .public('http://localhost:8080') - .host('localhost') + .public("http://localhost:8080") + .host("localhost") .port(8080) .hotOnly(true) - .watchOptions({poll: 500}) + .watchOptions({ poll: 500 }) .https(false) - .headers({"Access-Control-Allow-Origin": ["*"]}) - - } -}; + .headers({ "Access-Control-Allow-Origin": ["*"] }) + }, +} From ea62c10d9ab2f3fa824c150c8b4ae8c28a3bc5e8 Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 16 Dec 2021 09:20:56 -0600 Subject: [PATCH 02/91] remove console message --- vue/src/apps/RecipeSearchView/RecipeSearchView.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue index 37eb1ad10..c1adc7ddd 100644 --- a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue +++ b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue @@ -376,7 +376,6 @@ export default { // this.genericAPI inherited from ApiMixin refreshData: function (random) { this.random_search = random - console.log(this.debug, typeof this.debug) let params = { query: this.settings.search_input, keywords: this.settings.search_keywords, From 84759383fa27cf7d11bdb1e2350a3585937dc597 Mon Sep 17 00:00:00 2001 From: Oliver Cervera Date: Sat, 18 Dec 2021 13:49:09 +0100 Subject: [PATCH 03/91] Add documentation about swag by linuxserver Documents behaviour in #959 --- docs/install/docker.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/install/docker.md b/docs/install/docker.md index 2261d4815..78130a07e 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -111,6 +111,28 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/d {% include "./docker/nginx-proxy/docker-compose.yml" %} ~~~ +#### Nginx Swag by LinuxServer +[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io + +It also contains templates for popular apps, including Tandoor Recipes, so you don't have to manually configure nginx and discard the template provided in Tandoor repo. Tandoor config is called `recipes.subdomain.conf.sample` which you can adapt for your instance + +If you're running Swag on the default port, you'll just need to change the container name to yours. + +If your running Swag on a custom port, some headers must be changed. To do this, + +- Create a copy of `proxy.conf` +- Replace `proxy_set_header X-Forwarded-Host $host;` and `proxy_set_header Host $host;` to + - `proxy_set_header X-Forwarded-Host $http_host;` and `proxy_set_header Host $http_host;` +- Update `recipes.subdomain.conf` to use the new file +- Restart the linuxserver/swag container and Recipes will work + +More information [here](https://github.com/TandoorRecipes/recipes/issues/959#issuecomment-962648627). + + +In both cases, also make sure to mount `/media/` in your swag container to point to your Tandoor Recipes Media directory. + +Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup. + ## Additional Information ### Nginx vs Gunicorn From ecbd3edb971cef7e5ff1411861eaa1928baae0c4 Mon Sep 17 00:00:00 2001 From: smilerz Date: Sun, 19 Dec 2021 10:21:37 -0600 Subject: [PATCH 04/91] WIP --- vue/src/apps/ModelListView/ModelListView.vue | 2 +- vue/src/apps/RecipeSearchView/RecipeSearchView.vue | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/vue/src/apps/ModelListView/ModelListView.vue b/vue/src/apps/ModelListView/ModelListView.vue index 0f7cd7499..2d79c865a 100644 --- a/vue/src/apps/ModelListView/ModelListView.vue +++ b/vue/src/apps/ModelListView/ModelListView.vue @@ -435,7 +435,7 @@ export default { getRecipes: function (col, item) { let parent = {} // TODO: make this generic - let params = { pageSize: 50 } + let params = { pageSize: 50, random: true } params[this.this_recipe_param] = item.id console.log("RECIPE PARAM", this.this_recipe_param, params, item.id) this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params) diff --git a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue index c1adc7ddd..6984c3c8a 100644 --- a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue +++ b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue @@ -396,6 +396,7 @@ export default { if (!this.searchFiltered) { params.options = { query: { last_viewed: this.settings.recently_viewed } } } + console.log(params) this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => { window.scrollTo(0, 0) this.pagination_count = result.data.count From cf3ddfc610b02f6f6a1cdf526bb9b5e86e36b21a Mon Sep 17 00:00:00 2001 From: smilerz Date: Sun, 19 Dec 2021 11:08:30 -0600 Subject: [PATCH 05/91] fix inconsistent pagination --- vue/src/apps/RecipeSearchView/RecipeSearchView.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue index 6984c3c8a..81f5aad5a 100644 --- a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue +++ b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue @@ -304,7 +304,7 @@ export default { this.settings?.search_keywords?.length === 0 && this.settings?.search_foods?.length === 0 && this.settings?.search_books?.length === 0 && - this.settings?.pagination_page === 1 && + // this.settings?.pagination_page === 1 && !this.random_search && this.settings?.search_ratings === undefined ) { @@ -396,7 +396,7 @@ export default { if (!this.searchFiltered) { params.options = { query: { last_viewed: this.settings.recently_viewed } } } - console.log(params) + console.log(2, params) this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => { window.scrollTo(0, 0) this.pagination_count = result.data.count From 1a99a2d6f1ef855842abcc8d5ce4e6242f988f23 Mon Sep 17 00:00:00 2001 From: smilerz Date: Sun, 19 Dec 2021 11:31:16 -0600 Subject: [PATCH 06/91] remove console.log --- vue/src/apps/RecipeSearchView/RecipeSearchView.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue index 81f5aad5a..471e337d4 100644 --- a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue +++ b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue @@ -396,7 +396,6 @@ export default { if (!this.searchFiltered) { params.options = { query: { last_viewed: this.settings.recently_viewed } } } - console.log(2, params) this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => { window.scrollTo(0, 0) this.pagination_count = result.data.count From e470a703212bf6e133dea90c6d30152a7cff4bd6 Mon Sep 17 00:00:00 2001 From: Oliver Cervera Date: Mon, 20 Dec 2021 11:09:05 +0100 Subject: [PATCH 07/91] punctuation fixes First commit was done in rush. Fixed a couple of punctuations. --- docs/install/docker.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/install/docker.md b/docs/install/docker.md index 78130a07e..23fc02a41 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -112,19 +112,19 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/d ~~~ #### Nginx Swag by LinuxServer -[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io +[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io. -It also contains templates for popular apps, including Tandoor Recipes, so you don't have to manually configure nginx and discard the template provided in Tandoor repo. Tandoor config is called `recipes.subdomain.conf.sample` which you can adapt for your instance +It contains templates for popular apps, including Tandoor Recipes, so you don't have to manually configure nginx and discard the template provided in Tandoor repo. Tandoor config is called `recipes.subdomain.conf.sample` which you can adapt for your instance. If you're running Swag on the default port, you'll just need to change the container name to yours. -If your running Swag on a custom port, some headers must be changed. To do this, +If your running Swag on a custom port, some headers must be changed: - Create a copy of `proxy.conf` - Replace `proxy_set_header X-Forwarded-Host $host;` and `proxy_set_header Host $host;` to - `proxy_set_header X-Forwarded-Host $http_host;` and `proxy_set_header Host $http_host;` - Update `recipes.subdomain.conf` to use the new file -- Restart the linuxserver/swag container and Recipes will work +- Restart the linuxserver/swag container and Recipes will work correctly More information [here](https://github.com/TandoorRecipes/recipes/issues/959#issuecomment-962648627). From 5d33d82d70c13fb5e09dd6025a997ba53da977fa Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 23 Dec 2021 07:38:30 -0600 Subject: [PATCH 08/91] fixes 1129 --- cookbook/helper/recipe_html_import.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cookbook/helper/recipe_html_import.py b/cookbook/helper/recipe_html_import.py index 7b779add0..acf72917b 100644 --- a/cookbook/helper/recipe_html_import.py +++ b/cookbook/helper/recipe_html_import.py @@ -1,13 +1,14 @@ import json import re +from json import JSONDecodeError +from urllib.parse import unquote from bs4 import BeautifulSoup from bs4.element import Tag +from recipe_scrapers._utils import get_host_name, normalize_string + from cookbook.helper import recipe_url_import as helper from cookbook.helper.scrapers.scrapers import text_scraper -from json import JSONDecodeError -from recipe_scrapers._utils import get_host_name, normalize_string -from urllib.parse import unquote def get_recipe_from_source(text, url, request): @@ -58,7 +59,7 @@ def get_recipe_from_source(text, url, request): return kid_list recipe_json = { - 'name': '', + 'name': '', 'url': '', 'description': '', 'image': '', @@ -188,6 +189,6 @@ def remove_graph(el): for x in el['@graph']: if '@type' in x and x['@type'] == 'Recipe': el = x - except TypeError: + except (TypeError, JSONDecodeError): pass return el From 05f2fdecb34e0d7c36a2d71ba3326a3fe6c64ad9 Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 23 Dec 2021 07:44:11 -0600 Subject: [PATCH 09/91] fixes 1123 --- cookbook/templates/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cookbook/templates/base.html b/cookbook/templates/base.html index 9448ac408..b88570f4d 100644 --- a/cookbook/templates/base.html +++ b/cookbook/templates/base.html @@ -67,7 +67,7 @@ {% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %} - + {% endif %} From 3ff15b6766ffd8c0638d1777075da2cda5cf8551 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Thu, 23 Dec 2021 15:54:48 +0100 Subject: [PATCH 10/91] added copy me that importer --- cookbook/forms.py | 3 +- cookbook/integration/copymethat.py | 84 +++++++++++++++++++++++++++++ cookbook/integration/integration.py | 13 ++++- cookbook/templates/url_import.html | 1 + cookbook/views/import_export.py | 3 ++ docs/features/import_export.md | 5 ++ 6 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 cookbook/integration/copymethat.py diff --git a/cookbook/forms.py b/cookbook/forms.py index 83ee6232b..37a326383 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -152,13 +152,14 @@ class ImportExportBase(forms.Form): OPENEATS = 'OPENEATS' PLANTOEAT = 'PLANTOEAT' COOKBOOKAPP = 'COOKBOOKAPP' + COPYMETHAT = 'COPYMETHAT' type = forms.ChoiceField(choices=( (DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFRON, 'Safron'), (CHEFTAP, 'ChefTap'), (PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'), (MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'), - (PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), + (PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), )) diff --git a/cookbook/integration/copymethat.py b/cookbook/integration/copymethat.py new file mode 100644 index 000000000..4f4a217e5 --- /dev/null +++ b/cookbook/integration/copymethat.py @@ -0,0 +1,84 @@ +import re +from io import BytesIO +from zipfile import ZipFile + +from bs4 import BeautifulSoup + +from cookbook.helper.ingredient_parser import IngredientParser +from cookbook.helper.recipe_html_import import get_recipe_from_source +from cookbook.helper.recipe_url_import import iso_duration_to_minutes, parse_servings +from cookbook.integration.integration import Integration +from cookbook.models import Recipe, Step, Ingredient, Keyword +from recipes.settings import DEBUG + + +class CopyMeThat(Integration): + + def import_file_name_filter(self, zip_info_object): + if DEBUG: + print("testing", zip_info_object.filename, zip_info_object.filename == 'recipes.html') + return zip_info_object.filename == 'recipes.html' + + def get_recipe_from_file(self, file): + # 'file' comes is as a beautifulsoup object + recipe = Recipe.objects.create(name=file.find("div", {"id": "name"}).text.strip(), created_by=self.request.user, internal=True, space=self.request.space, ) + + for category in file.find_all("span", {"class": "recipeCategory"}): + keyword, created = Keyword.objects.get_or_create(name=category.text, space=self.request.space) + recipe.keywords.add(keyword) + + try: + recipe.servings = parse_servings(file.find("a", {"id": "recipeYield"}).text.strip()) + recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip()) + recipe.waiting_time = iso_duration_to_minutes(file.find("span", {"meta": "cookTime"}).text.strip()) + recipe.save() + except AttributeError: + pass + + step = Step.objects.create(instruction='', space=self.request.space, ) + + ingredient_parser = IngredientParser(self.request, True) + for ingredient in file.find_all("li", {"class": "recipeIngredient"}): + if ingredient.text == "": + continue + amount, unit, ingredient, note = ingredient_parser.parse(ingredient.text.strip()) + f = ingredient_parser.get_food(ingredient) + u = ingredient_parser.get_unit(unit) + step.ingredients.add(Ingredient.objects.create( + food=f, unit=u, amount=amount, note=note, space=self.request.space, + )) + + for s in file.find_all("li", {"class": "instruction"}): + if s.text == "": + continue + step.instruction += s.text.strip() + ' \n\n' + + for s in file.find_all("li", {"class": "recipeNote"}): + if s.text == "": + continue + step.instruction += s.text.strip() + ' \n\n' + + try: + if file.find("a", {"id": "original_link"}).text != '': + step.instruction += "\n\nImported from: " + file.find("a", {"id": "original_link"}).text + step.save() + except AttributeError: + pass + + recipe.steps.add(step) + + # import the Primary recipe image that is stored in the Zip + try: + for f in self.files: + if '.zip' in f['name']: + import_zip = ZipFile(f['file']) + self.import_recipe_image(recipe, BytesIO(import_zip.read(file.find("img", class_="recipeImage").get("src"))), filetype='.jpeg') + except Exception as e: + print(recipe.name, ': failed to import image ', str(e)) + + recipe.save() + return recipe + + def split_recipe_file(self, file): + soup = BeautifulSoup(file, "html.parser") + return soup.find_all("div", {"class": "recipe"}) diff --git a/cookbook/integration/integration.py b/cookbook/integration/integration.py index d9b80eb9f..2e5ac8513 100644 --- a/cookbook/integration/integration.py +++ b/cookbook/integration/integration.py @@ -5,6 +5,7 @@ import uuid from io import BytesIO, StringIO from zipfile import ZipFile, BadZipFile +from bs4 import Tag from django.core.exceptions import ObjectDoesNotExist from django.core.files import File from django.db import IntegrityError @@ -16,7 +17,7 @@ from django_scopes import scope from cookbook.forms import ImportExportBase from cookbook.helper.image_processing import get_filetype, handle_image from cookbook.models import Keyword, Recipe -from recipes.settings import DATABASES, DEBUG +from recipes.settings import DEBUG class Integration: @@ -153,9 +154,17 @@ class Integration: file_list.append(z) il.total_recipes += len(file_list) + import cookbook + if isinstance(self, cookbook.integration.copymethat.CopyMeThat): + file_list = self.split_recipe_file(BytesIO(import_zip.read('recipes.html'))) + il.total_recipes += len(file_list) + for z in file_list: try: - recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename))) + if isinstance(z, Tag): + recipe = self.get_recipe_from_file(z) + else: + recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename))) recipe.keywords.add(self.keyword) il.msg += f'{recipe.pk} - {recipe.name} \n' self.handle_duplicates(recipe, import_duplicates) diff --git a/cookbook/templates/url_import.html b/cookbook/templates/url_import.html index 6457f8761..983f60378 100644 --- a/cookbook/templates/url_import.html +++ b/cookbook/templates/url_import.html @@ -76,6 +76,7 @@ + diff --git a/cookbook/views/import_export.py b/cookbook/views/import_export.py index 0a8e83e16..ebbef836e 100644 --- a/cookbook/views/import_export.py +++ b/cookbook/views/import_export.py @@ -11,6 +11,7 @@ from django.utils.translation import gettext as _ from cookbook.forms import ExportForm, ImportForm, ImportExportBase from cookbook.helper.permission_helper import group_required from cookbook.integration.cookbookapp import CookBookApp +from cookbook.integration.copymethat import CopyMeThat from cookbook.integration.pepperplate import Pepperplate from cookbook.integration.cheftap import ChefTap from cookbook.integration.chowdown import Chowdown @@ -65,6 +66,8 @@ def get_integration(request, export_type): return Plantoeat(request, export_type) if export_type == ImportExportBase.COOKBOOKAPP: return CookBookApp(request, export_type) + if export_type == ImportExportBase.COPYMETHAT: + return CopyMeThat(request, export_type) @group_required('user') diff --git a/docs/features/import_export.md b/docs/features/import_export.md index d53c30c04..9db829ecb 100644 --- a/docs/features/import_export.md +++ b/docs/features/import_export.md @@ -37,6 +37,7 @@ Overview of the capabilities of the different integrations. | OpenEats | ✔️ | ❌ | ⌚ | | Plantoeat | ✔️ | ❌ | ✔ | | CookBookApp | ✔️ | ⌚ | ✔️ | +| CopyMeThat | ✔️ | ❌ | ✔️ | ✔ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented @@ -218,3 +219,7 @@ Plan to eat allows you to export a text file containing all your recipes. Simply ## CookBookApp CookBookApp can export .zip files containing .html files. Upload the entire ZIP to Tandoor to import all included recipes. + +## CopyMeThat + +CopyMeThat can export .zip files containing an `.html` file as well as a folder containing all the images. Upload the entire ZIP to Tandoor to import all included recipes. \ No newline at end of file From 64f278794396a4295567d5a1693b616b56c22f5a Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 23 Dec 2021 14:48:30 -0600 Subject: [PATCH 11/91] fix logout redirect --- recipes/settings.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/recipes/settings.py b/recipes/settings.py index 6edb406b7..0e79da2e2 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -56,6 +56,7 @@ CORS_ORIGIN_ALLOW_ALL = True LOGIN_REDIRECT_URL = "index" LOGOUT_REDIRECT_URL = "index" +ACCOUNT_LOGOUT_REDIRECT_URL = "index" SESSION_EXPIRE_AT_BROWSER_CLOSE = False SESSION_COOKIE_AGE = 365 * 60 * 24 * 60 @@ -163,12 +164,12 @@ if ENABLE_METRICS: AUTHENTICATION_BACKENDS = [] # LDAP -LDAP_AUTH=bool(os.getenv('LDAP_AUTH', False)) +LDAP_AUTH = bool(os.getenv('LDAP_AUTH', False)) if LDAP_AUTH: import ldap from django_auth_ldap.config import LDAPSearch AUTHENTICATION_BACKENDS.append('django_auth_ldap.backend.LDAPBackend') - AUTH_LDAP_SERVER_URI = os.getenv('AUTH_LDAP_SERVER_URI') + AUTH_LDAP_SERVER_URI = os.getenv('AUTH_LDAP_SERVER_URI') AUTH_LDAP_BIND_DN = os.getenv('AUTH_LDAP_BIND_DN') AUTH_LDAP_BIND_PASSWORD = os.getenv('AUTH_LDAP_BIND_PASSWORD') AUTH_LDAP_USER_SEARCH = LDAPSearch( From 10a33add75cb2d502d96811bb0c8aca96168b52d Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 28 Oct 2021 07:35:30 -0500 Subject: [PATCH 12/91] Fix after rebase --- cookbook/admin.py | 2 +- cookbook/forms.py | 88 +- cookbook/helper/HelperFunctions.py | 13 + cookbook/helper/permission_helper.py | 12 +- cookbook/helper/recipe_search.py | 16 +- cookbook/helper/shopping_helper.py | 40 + cookbook/integration/integration.py | 2 +- .../0159_add_shoppinglistentry_fields.py | 149 ++ .../0160_delete_shoppinglist_orphans.py | 50 + .../0161_alter_shoppinglistentry_food.py | 19 + cookbook/models.py | 257 +++- cookbook/serializer.py | 210 ++- cookbook/signals.py | 73 +- cookbook/templates/base.html | 2 +- .../templates/generic/checklist_template.html | 32 - cookbook/templates/generic/list_template.html | 21 +- cookbook/templates/settings.html | 39 + cookbook/templates/shopping_list.html | 1 + cookbook/templates/shoppinglist_template.html | 17 + cookbook/templates/space.html | 299 ++-- cookbook/tests/api/test_api_food.py | 20 +- cookbook/tests/api/test_api_recipe.py | 13 + .../tests/api/test_api_shopping_list_entry.py | 19 +- cookbook/tests/api/test_api_step.py | 10 +- cookbook/tests/api/test_api_unit.py | 6 +- cookbook/tests/api/test_api_userpreference.py | 7 +- cookbook/urls.py | 34 +- cookbook/views/api.py | 143 +- cookbook/views/data.py | 8 +- cookbook/views/lists.py | 29 +- cookbook/views/views.py | 54 +- recipes/middleware.py | 4 +- vue/.gitignore | 23 - vue/.openapi-generator/FILES | 8 + vue/.openapi-generator/VERSION | 1 + vue/src/apps/ChecklistView/ChecklistView.vue | 195 --- vue/src/apps/ChecklistView/main.js | 18 - vue/src/apps/CookbookView/CookbookView.vue | 8 +- vue/src/apps/MealPlanView/MealPlanView.vue | 1333 +++++++++-------- vue/src/apps/ModelListView/ModelListView.vue | 61 +- .../apps/RecipeEditView/RecipeEditView.vue | 1 - vue/src/apps/RecipeView/RecipeView.vue | 417 +++--- .../ShoppingListView/ShoppingListView.vue | 805 ++++++++++ vue/src/apps/ShoppingListView/main.js | 10 + vue/src/components/Badges.vue | 14 +- vue/src/components/Badges/Icon.vue | 2 +- vue/src/components/Badges/LinkedRecipe.vue | 2 +- vue/src/components/Badges/OnHand.vue | 40 + vue/src/components/Badges/Shopping.vue | 94 ++ .../components/ContextMenu/ContextMenu.vue | 191 ++- .../ContextMenu/ContextMenuItem.vue | 13 +- .../ContextMenu/ContextMenuSubmenu.vue | 34 + .../{ => ContextMenu}/GenericContextMenu.vue | 0 .../{ => ContextMenu}/ModelMenu.vue | 0 .../{ => ContextMenu}/RecipeContextMenu.vue | 22 +- vue/src/components/GenericHorizontalCard.vue | 4 +- vue/src/components/IngredientComponent.vue | 269 +++- vue/src/components/IngredientsCard.vue | 187 +++ vue/src/components/MealPlanEditModal.vue | 216 --- .../{ => Modals}/AddRecipeToBook.vue | 0 .../components/Modals/GenericModalForm.vue | 6 +- .../components/Modals/MealPlanEditModal.vue | 219 +++ vue/src/components/Modals/ShoppingModal.vue | 158 ++ vue/src/components/RecipeCard.vue | 247 ++- vue/src/components/ShoppingLineItem.vue | 269 ++++ vue/src/components/StepComponent.vue | 17 +- vue/src/locales/en.json | 44 + vue/src/utils/api.js | 7 +- vue/src/utils/apiv2.js | 16 + vue/src/utils/models.js | 89 +- vue/src/utils/openapi/api.ts | 1330 ++++++++++++---- vue/src/utils/utils.js | 27 +- vue/vue.config.js | 17 +- 73 files changed, 5579 insertions(+), 2524 deletions(-) create mode 100644 cookbook/helper/HelperFunctions.py create mode 100644 cookbook/helper/shopping_helper.py create mode 100644 cookbook/migrations/0159_add_shoppinglistentry_fields.py create mode 100644 cookbook/migrations/0160_delete_shoppinglist_orphans.py create mode 100644 cookbook/migrations/0161_alter_shoppinglistentry_food.py delete mode 100644 cookbook/templates/generic/checklist_template.html create mode 100644 cookbook/templates/shoppinglist_template.html delete mode 100644 vue/.gitignore create mode 100644 vue/.openapi-generator/FILES create mode 100644 vue/.openapi-generator/VERSION delete mode 100644 vue/src/apps/ChecklistView/ChecklistView.vue delete mode 100644 vue/src/apps/ChecklistView/main.js create mode 100644 vue/src/apps/ShoppingListView/ShoppingListView.vue create mode 100644 vue/src/apps/ShoppingListView/main.js create mode 100644 vue/src/components/Badges/OnHand.vue create mode 100644 vue/src/components/Badges/Shopping.vue create mode 100644 vue/src/components/ContextMenu/ContextMenuSubmenu.vue rename vue/src/components/{ => ContextMenu}/GenericContextMenu.vue (100%) rename vue/src/components/{ => ContextMenu}/ModelMenu.vue (100%) rename vue/src/components/{ => ContextMenu}/RecipeContextMenu.vue (89%) create mode 100644 vue/src/components/IngredientsCard.vue delete mode 100644 vue/src/components/MealPlanEditModal.vue rename vue/src/components/{ => Modals}/AddRecipeToBook.vue (100%) create mode 100644 vue/src/components/Modals/MealPlanEditModal.vue create mode 100644 vue/src/components/Modals/ShoppingModal.vue create mode 100644 vue/src/components/ShoppingLineItem.vue create mode 100644 vue/src/utils/apiv2.js diff --git a/cookbook/admin.py b/cookbook/admin.py index 4c4fc5220..2237c5163 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 37a326383..1801efd95 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 000000000..cf04c3e2d --- /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 cb3f791de..ea6bcdb58 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 850eb87db..ce87e0aae 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 000000000..26d590f85 --- /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 2e5ac8513..cecab4a20 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 000000000..9df880d08 --- /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 000000000..27fb0edb9 --- /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 000000000..ab5653545 --- /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 aed927bde..d4d7d6985 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 20c6fec21..24c333f46 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 dc820c113..10ca5262c 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 b88570f4d..6dd0943c1 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 673d9103e..7d4be443d 100644 --- a/cookbook/templates/generic/list_template.html +++ b/cookbook/templates/generic/list_template.html @@ -18,12 +18,23 @@ {% endif %}
-

{{ title }} {% trans 'List' %} - {% if create_url %} - + +

{{ title }} {% trans 'List' %} + {% if create_url %} + + + {% endif %} +

+ + {% if request.resolver_match.url_name in 'list_shopping_list' %} + + + - {% endif %} -

+ + {% endif %} {% if filter %}
diff --git a/cookbook/templates/settings.html b/cookbook/templates/settings.html index ed591b9ac..e33a3563c 100644 --- a/cookbook/templates/settings.html +++ b/cookbook/templates/settings.html @@ -48,6 +48,13 @@ aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}"> {% trans 'Search-Settings' %} + @@ -195,6 +202,17 @@ class="fas fa-save"> {% trans 'Save' %}
+
+

{% trans 'Shopping Settings' %}

+ +
+ {% csrf_token %} + {{ shopping_form|crispy }} + +
+
@@ -224,5 +242,26 @@ $('.nav-tabs a').on('shown.bs.tab', function (e) { window.location.hash = e.target.hash; }) + // listen for events + $(document).ready(function(){ + hideShow() + // call hideShow when the user clicks on the mealplan_autoadd checkbox + $("#id_shopping-mealplan_autoadd_shopping").click(function(event){ + hideShow() + }); + }) + + function hideShow(){ + if(document.getElementById('id_shopping-mealplan_autoadd_shopping').checked == true) + { + $('#div_id_shopping-mealplan_autoexclude_onhand').show(); + $('#div_id_shopping-mealplan_autoinclude_related').show(); + } + else + { + $('#div_id_shopping-mealplan_autoexclude_onhand').hide(); + $('#div_id_shopping-mealplan_autoinclude_related').hide(); + } + } {% endblock %} diff --git a/cookbook/templates/shopping_list.html b/cookbook/templates/shopping_list.html index e16ce82a0..b2b7175cd 100644 --- a/cookbook/templates/shopping_list.html +++ b/cookbook/templates/shopping_list.html @@ -655,6 +655,7 @@ if (this.shopping_list.entries.length === 0) { this.edit_mode = true } + console.log(response.data) }).catch((err) => { console.log(err) this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger') diff --git a/cookbook/templates/shoppinglist_template.html b/cookbook/templates/shoppinglist_template.html new file mode 100644 index 000000000..95e88237b --- /dev/null +++ b/cookbook/templates/shoppinglist_template.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} {% load render_bundle from webpack_loader %} {% load static %} {% load i18n %} {% block title %} {{ title }} {% endblock %} {% block content_fluid %} + +
+ +
+ +{% endblock %} {% block script %} {% if debug %} + +{% else %} + +{% endif %} + + + +{% render_bundle 'shopping_list_view' %} {% endblock %} diff --git a/cookbook/templates/space.html b/cookbook/templates/space.html index 1e21d3f39..1447527af 100644 --- a/cookbook/templates/space.html +++ b/cookbook/templates/space.html @@ -1,165 +1,188 @@ {% extends "base.html" %} {% load django_tables2 %} +{% load crispy_forms_tags %} {% load crispy_forms_filters %} {% load static %} {% load i18n %} -{% block title %}{% trans "Space Settings" %}{% endblock %} +{%block title %} {% trans "Space Settings" %} {% endblock %} {% block extra_head %} {{ form.media }} - + {{ space_form.media }} {% include 'include/vue_base.html' %} {% endblock %} {% block content %} - + -

{% trans 'Space:' %} {{ request.space.name }} {% if HOSTED %} - {% trans 'Manage Subscription' %}{% endif %}

+

+ {% trans 'Space:' %} {{ request.space.name }} + {% if HOSTED %} {% trans 'Manage Subscription' %}{% endif %} +

-
+
-
-
-
-
- {% trans 'Number of objects' %} -
-
    -
  • {% trans 'Recipes' %} : {{ counts.recipes }} / - {% if request.space.max_recipes > 0 %} - {{ request.space.max_recipes }}{% else %}∞{% endif %}
  • -
  • {% trans 'Keywords' %} : {{ counts.keywords }}
  • -
  • {% trans 'Units' %} : {{ counts.units }}
  • -
  • {% trans 'Ingredients' %} : {{ counts.ingredients }}
  • -
  • {% trans 'Recipe Imports' %} : {{ counts.recipe_import }}
  • -
-
-
-
-
-
- {% trans 'Objects stats' %} -
-
    -
  • {% trans 'Recipes without Keywords' %} : {{ counts.recipes_no_keyword }}
  • -
  • {% trans 'External Recipes' %} : {{ counts.recipes_external }}
  • -
  • {% trans 'Internal Recipes' %} : {{ counts.recipes_internal }}
  • -
  • {% trans 'Comments' %} : {{ counts.comments }}
  • -
-
+
+
+
+
{% trans 'Number of objects' %}
+
    +
  • + {% trans 'Recipes' %} : + {{ counts.recipes }} / {% if request.space.max_recipes > 0 %} {{ request.space.max_recipes }}{% + else %}∞{% endif %} +
  • +
  • + {% trans 'Keywords' %} : {{ counts.keywords }} +
  • +
  • + {% trans 'Units' %} : {{ counts.units }} +
  • +
  • + {% trans 'Ingredients' %} : + {{ counts.ingredients }} +
  • +
  • + {% trans 'Recipe Imports' %} : + {{ counts.recipe_import }} +
  • +
-
-
-
-
- -

{% trans 'Members' %} {{ space_users|length }}/ - {% if request.space.max_users > 0 %} - {{ request.space.max_users }}{% else %}∞{% endif %} - {% trans 'Invite User' %} -

+
+
+
{% trans 'Objects stats' %}
+
    +
  • + {% trans 'Recipes without Keywords' %} : + {{ counts.recipes_no_keyword }} +
  • +
  • + {% trans 'External Recipes' %} : + {{ counts.recipes_external }} +
  • +
  • + {% trans 'Internal Recipes' %} : + {{ counts.recipes_internal }} +
  • +
  • + {% trans 'Comments' %} : {{ counts.comments }} +
  • +
-
- -
-
- {% if space_users %} - - - - - - - {% for u in space_users %} - - - - - - {% endfor %} -
{% 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 %} -
- {% else %} -

{% trans 'There are no members in your space yet!' %}

- {% endif %} -
+
+
+
+
{% csrf_token %} {{ user_name_form|crispy }}
+
+
+

+ {% trans 'Members' %} + {{ space_users|length }}/ {% if request.space.max_users > 0 %} {{ request.space.max_users }}{% else + %}∞{% endif %} + {% trans 'Invite User' %} +

+
+
-
-
-

{% trans 'Invite Links' %}

- {% render_table invite_links %} -
+
+
+ {% if space_users %} + + + + + + + {% for u in space_users %} + + + + + + {% endfor %} +
{% 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 %} +
+ {% else %} +

{% trans 'There are no members in your space yet!' %}

+ {% endif %}
+
-
-
-
+
+
+

{% trans 'Invite Links' %}

+ {% render_table invite_links %} +
+
+
+ +
+

{% trans 'Space Settings' %}

+
+ {% csrf_token %} + {{ space_form|crispy }} + +
+
+
+ +
+
+
+ +{% endblock %} {% block script %} + + {% endblock %} - -{% block script %} - - - -{% endblock %} \ No newline at end of file diff --git a/cookbook/tests/api/test_api_food.py b/cookbook/tests/api/test_api_food.py index a1b30d3c1..cb6843fef 100644 --- a/cookbook/tests/api/test_api_food.py +++ b/cookbook/tests/api/test_api_food.py @@ -1,10 +1,9 @@ import json + import pytest - from django.contrib import auth -from django_scopes import scopes_disabled from django.urls import reverse - +from django_scopes import scopes_disabled from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry @@ -74,7 +73,7 @@ def ing_1_1_s1(obj_1_1, space_1): @pytest.fixture() def sle_1_s1(obj_1, u1_s1, space_1): - e = ShoppingListEntry.objects.create(food=obj_1) + e = ShoppingListEntry.objects.create(food=obj_1, created_by=auth.get_user(u1_s1), space=space_1,) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -82,12 +81,12 @@ def sle_1_s1(obj_1, u1_s1, space_1): @pytest.fixture() def sle_2_s1(obj_2, u1_s1, space_1): - return ShoppingListEntry.objects.create(food=obj_2) + return ShoppingListEntry.objects.create(food=obj_2, created_by=auth.get_user(u1_s1), space=space_1,) @pytest.fixture() def sle_3_s2(obj_3, u1_s2, space_2): - e = ShoppingListEntry.objects.create(food=obj_3) + e = ShoppingListEntry.objects.create(food=obj_3, created_by=auth.get_user(u1_s2), space=space_2) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s2), space=space_2, ) s.entries.add(e) return e @@ -95,7 +94,7 @@ def sle_3_s2(obj_3, u1_s2, space_2): @pytest.fixture() def sle_1_1_s1(obj_1_1, u1_s1, space_1): - e = ShoppingListEntry.objects.create(food=obj_1_1) + e = ShoppingListEntry.objects.create(food=obj_1_1, created_by=auth.get_user(u1_s1), space=space_1,) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -449,3 +448,10 @@ def test_tree_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1): assert response['count'] == 4 response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}&query={obj_2.name[4:]}').content) assert response['count'] == 4 + + +# TODO test inherit creating, moving for each field type +# TODO test ignore inherit for each field type +# TODO test with grand-children +# - flow from parent through child and grand-child +# - flow from parent stop when child is ignore inherit diff --git a/cookbook/tests/api/test_api_recipe.py b/cookbook/tests/api/test_api_recipe.py index feedcde22..e018d957b 100644 --- a/cookbook/tests/api/test_api_recipe.py +++ b/cookbook/tests/api/test_api_recipe.py @@ -111,3 +111,16 @@ def test_delete(u1_s1, u1_s2, recipe_1_s1): assert r.status_code == 204 assert not Recipe.objects.filter(pk=recipe_1_s1.id).exists() + + +# TODO test related_recipes api +# -- step recipes +# -- ingredient recipes +# -- recipe wrong space +# -- steps wrong space +# -- ingredients wrong space +# -- step recipes included in step recipes +# -- step recipes included in food recipes +# -- food recipes included in step recipes +# -- food recipes included in food recipes +# -- included recipes in the wrong space \ No newline at end of file diff --git a/cookbook/tests/api/test_api_shopping_list_entry.py b/cookbook/tests/api/test_api_shopping_list_entry.py index 71a1f8f6e..846f67d86 100644 --- a/cookbook/tests/api/test_api_shopping_list_entry.py +++ b/cookbook/tests/api/test_api_shopping_list_entry.py @@ -14,7 +14,7 @@ DETAIL_URL = 'api:shoppinglistentry-detail' @pytest.fixture() def obj_1(space_1, u1_s1): - e = ShoppingListEntry.objects.create(food=Food.objects.get_or_create(name='test 1', space=space_1)[0]) + e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -22,7 +22,7 @@ def obj_1(space_1, u1_s1): @pytest.fixture def obj_2(space_1, u1_s1): - e = ShoppingListEntry.objects.create(food=Food.objects.get_or_create(name='test 2', space=space_1)[0]) + e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 2', space=space_1)[0], space=space_1) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -45,8 +45,11 @@ def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2): with scopes_disabled(): s = ShoppingList.objects.first() + e = ShoppingListEntry.objects.first() s.space = space_2 + e.space = space_2 s.save() + e.save() assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1 assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0 @@ -114,3 +117,15 @@ def test_delete(u1_s1, u1_s2, obj_1): ) assert r.status_code == 204 + + +# TODO test sharing +# TODO test completed entries still visible if today, but not yesterday +# TODO test create shopping list from recipe +# TODO test delete shopping list from recipe - include created by, shared with and not shared with +# TODO test create shopping list from food +# TODO test delete shopping list from food - include created by, shared with and not shared with +# TODO test create shopping list from mealplan +# TODO test create shopping list from recipe, excluding ingredients +# TODO test auto creating shopping list from meal plan +# TODO test excluding on-hand when auto creating shopping list diff --git a/cookbook/tests/api/test_api_step.py b/cookbook/tests/api/test_api_step.py index 209a8091d..f2a809632 100644 --- a/cookbook/tests/api/test_api_step.py +++ b/cookbook/tests/api/test_api_step.py @@ -23,8 +23,8 @@ def test_list_permission(arg, request): def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2): - assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 2 - assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 0 + assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2 + assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 0 with scopes_disabled(): recipe_1_s1.space = space_2 @@ -32,9 +32,9 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2): Step.objects.update(space=Subquery(Step.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1])) Ingredient.objects.update(space=Subquery(Ingredient.objects.filter(pk=OuterRef('pk')).values('step__recipe__space')[:1])) - assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 0 - assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 2 - + assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 0 + assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2 + @pytest.mark.parametrize("arg", [ ['a_u', 403], diff --git a/cookbook/tests/api/test_api_unit.py b/cookbook/tests/api/test_api_unit.py index 13d0aaece..8c4099ce7 100644 --- a/cookbook/tests/api/test_api_unit.py +++ b/cookbook/tests/api/test_api_unit.py @@ -49,7 +49,7 @@ def ing_3_s2(obj_3, space_2, u2_s2): @pytest.fixture() def sle_1_s1(obj_1, u1_s1, space_1): - e = ShoppingListEntry.objects.create(unit=obj_1, food=random_food(space_1, u1_s1)) + e = ShoppingListEntry.objects.create(unit=obj_1, food=random_food(space_1, u1_s1), created_by=auth.get_user(u1_s1), space=space_1,) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -57,12 +57,12 @@ def sle_1_s1(obj_1, u1_s1, space_1): @pytest.fixture() def sle_2_s1(obj_2, u1_s1, space_1): - return ShoppingListEntry.objects.create(unit=obj_2, food=random_food(space_1, u1_s1)) + return ShoppingListEntry.objects.create(unit=obj_2, food=random_food(space_1, u1_s1), created_by=auth.get_user(u1_s1), space=space_1,) @pytest.fixture() def sle_3_s2(obj_3, u2_s2, space_2): - e = ShoppingListEntry.objects.create(unit=obj_3, food=random_food(space_2, u2_s2)) + e = ShoppingListEntry.objects.create(unit=obj_3, food=random_food(space_2, u2_s2), created_by=auth.get_user(u2_s2), space=space_2) s = ShoppingList.objects.create(created_by=auth.get_user(u2_s2), space=space_2) s.entries.add(e) return e diff --git a/cookbook/tests/api/test_api_userpreference.py b/cookbook/tests/api/test_api_userpreference.py index 4750fbaa4..a78721c33 100644 --- a/cookbook/tests/api/test_api_userpreference.py +++ b/cookbook/tests/api/test_api_userpreference.py @@ -1,5 +1,3 @@ -from cookbook.models import UserPreference - import json import pytest @@ -7,6 +5,8 @@ from django.contrib import auth from django.urls import reverse from django_scopes import scopes_disabled +from cookbook.models import UserPreference + LIST_URL = 'api:userpreference-list' DETAIL_URL = 'api:userpreference-detail' @@ -109,3 +109,6 @@ def test_preference_delete(u1_s1, u2_s1): ) ) assert r.status_code == 204 + + +# TODO test existance of default food_inherit fields, test multiple users same space work and users in difference space do not diff --git a/cookbook/urls.py b/cookbook/urls.py index 690eae013..68ac160b1 100644 --- a/cookbook/urls.py +++ b/cookbook/urls.py @@ -15,33 +15,35 @@ from .models import (Automation, Comment, Food, InviteLink, Keyword, MealPlan, R from .views import api, data, delete, edit, import_export, lists, new, telegram, views router = routers.DefaultRouter() -router.register(r'user-name', api.UserNameViewSet, basename='username') -router.register(r'user-preference', api.UserPreferenceViewSet) -router.register(r'storage', api.StorageViewSet) -router.register(r'sync', api.SyncViewSet) -router.register(r'sync-log', api.SyncLogViewSet) -router.register(r'keyword', api.KeywordViewSet) -router.register(r'unit', api.UnitViewSet) +router.register(r'automation', api.AutomationViewSet) +router.register(r'bookmarklet-import', api.BookmarkletImportViewSet) +router.register(r'cook-log', api.CookLogViewSet) router.register(r'food', api.FoodViewSet) -router.register(r'step', api.StepViewSet) -router.register(r'recipe', api.RecipeViewSet) +router.register(r'food-inherit-field', api.FoodInheritFieldViewSet) +router.register(r'import-log', api.ImportLogViewSet) router.register(r'ingredient', api.IngredientViewSet) +router.register(r'keyword', api.KeywordViewSet) router.register(r'meal-plan', api.MealPlanViewSet) router.register(r'meal-type', api.MealTypeViewSet) +router.register(r'recipe', api.RecipeViewSet) +router.register(r'recipe-book', api.RecipeBookViewSet) +router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet) router.register(r'shopping-list', api.ShoppingListViewSet) router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet) router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet) -router.register(r'view-log', api.ViewLogViewSet) -router.register(r'cook-log', api.CookLogViewSet) -router.register(r'recipe-book', api.RecipeBookViewSet) -router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet) +router.register(r'step', api.StepViewSet) +router.register(r'storage', api.StorageViewSet) router.register(r'supermarket', api.SupermarketViewSet) router.register(r'supermarket-category', api.SupermarketCategoryViewSet) router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet) -router.register(r'import-log', api.ImportLogViewSet) -router.register(r'bookmarklet-import', api.BookmarkletImportViewSet) +router.register(r'sync', api.SyncViewSet) +router.register(r'sync-log', api.SyncLogViewSet) +router.register(r'unit', api.UnitViewSet) router.register(r'user-file', api.UserFileViewSet) -router.register(r'automation', api.AutomationViewSet) +router.register(r'user-name', api.UserNameViewSet, basename='username') +router.register(r'user-preference', api.UserPreferenceViewSet) +router.register(r'view-log', api.ViewLogViewSet) + urlpatterns = [ path('', views.index, name='index'), diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 11a5d8ce2..99c234980 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -38,21 +38,25 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus from cookbook.helper.recipe_html_import import get_recipe_from_source from cookbook.helper.recipe_search import get_facet, old_search, search_recipes from cookbook.helper.recipe_url_import import get_from_scraper -from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, ImportLog, Ingredient, - Keyword, MealPlan, MealType, Recipe, RecipeBook, RecipeBookEntry, - ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Step, - Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation, - Sync, SyncLog, Unit, UserFile, UserPreference, ViewLog) +from cookbook.helper.shopping_helper import shopping_helper +from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField, + ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook, + RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry, + ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory, + SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile, + UserPreference, ViewLog) from cookbook.provider.dropbox import Dropbox from cookbook.provider.local import Local from cookbook.provider.nextcloud import Nextcloud from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer, - CookLogSerializer, FoodSerializer, ImportLogSerializer, + CookLogSerializer, FoodInheritFieldSerializer, FoodSerializer, + FoodShoppingUpdateSerializer, ImportLogSerializer, IngredientSerializer, KeywordSerializer, MealPlanSerializer, MealTypeSerializer, RecipeBookEntrySerializer, RecipeBookSerializer, RecipeImageSerializer, RecipeOverviewSerializer, RecipeSerializer, + RecipeShoppingUpdateSerializer, RecipeSimpleSerializer, ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer, ShoppingListRecipeSerializer, ShoppingListSerializer, StepSerializer, StorageSerializer, @@ -359,8 +363,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, StandardFilterMixin): permission_classes = [CustomIsUser] def get_queryset(self): - self.queryset = self.queryset.filter(space=self.request.space) - return super().get_queryset() + return self.queryset.filter(space=self.request.space) class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin): @@ -390,6 +393,16 @@ class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin): pagination_class = DefaultPagination +class FoodInheritFieldViewSet(viewsets.ReadOnlyModelViewSet): + queryset = FoodInheritField.objects + serializer_class = FoodInheritFieldSerializer + permission_classes = [CustomIsUser] + + def get_queryset(self): + # exclude fields not yet implemented + return Food.inherit_fields + + class FoodViewSet(viewsets.ModelViewSet, TreeMixin): queryset = Food.objects model = Food @@ -397,6 +410,23 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin): permission_classes = [CustomIsUser] pagination_class = DefaultPagination + @decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,) + def shopping(self, request, pk): + obj = self.get_object() + shared_users = list(self.request.user.get_shopping_share()) + shared_users.append(request.user) + if request.data.get('_delete', False) == 'true': + ShoppingListEntry.objects.filter(food=obj, checked=False, space=request.space, created_by__in=shared_users).delete() + content = {'msg': _(f'{obj.name} was removed from the shopping list.')} + return Response(content, status=status.HTTP_204_NO_CONTENT) + + amount = request.data.get('amount', 1) + unit = request.data.get('unit', None) + content = {'msg': _(f'{obj.name} was added to the shopping list.')} + + ShoppingListEntry.objects.create(food=obj, amount=amount, unit=unit, space=request.space, created_by=request.user) + return Response(content, status=status.HTTP_204_NO_CONTENT) + def destroy(self, *args, **kwargs): try: return (super().destroy(self, *args, **kwargs)) @@ -547,27 +577,18 @@ class RecipeViewSet(viewsets.ModelViewSet): pagination_class = RecipePagination # TODO the boolean params below (keywords_or through new) should be updated to boolean types with front end refactored accordingly query_params = [ - QueryParam(name='query', description=_( - 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.')), - QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter.'), - qtype='int'), - QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), - qtype='int'), + QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')), + QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter.'), qtype='int'), + QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'), QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'), QueryParam(name='rating', description=_('Rating a recipe should have. [0 - 5]'), qtype='int'), QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')), - QueryParam(name='keywords_or', description=_( - 'If recipe should have all (AND=''false'') or any (OR=''true'') of the provided keywords.')), - QueryParam(name='foods_or', description=_( - 'If recipe should have all (AND=''false'') or any (OR=''true'') of the provided foods.')), - QueryParam(name='books_or', description=_( - 'If recipe should be in all (AND=''false'') or any (OR=''true'') of the provided books.')), - QueryParam(name='internal', - description=_('If only internal recipes should be returned. [''true''/''false'']')), - QueryParam(name='random', - description=_('Returns the results in randomized order. [''true''/''false'']')), - QueryParam(name='new', - description=_('Returns new results first in search results. [''true''/''false'']')), + QueryParam(name='keywords_or', description=_('If recipe should have all (AND=''false'') or any (OR=''true'') of the provided keywords.')), + QueryParam(name='foods_or', description=_('If recipe should have all (AND=''false'') or any (OR=''true'') of the provided foods.')), + QueryParam(name='books_or', description=_('If recipe should be in all (AND=''false'') or any (OR=''true'') of the provided books.')), + QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''false'']')), + QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''false'']')), + QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''false'']')), ] schema = QueryParamAutoSchema() @@ -625,16 +646,49 @@ class RecipeViewSet(viewsets.ModelViewSet): return Response(serializer.data) return Response(serializer.errors, 400) + @decorators.action( + detail=True, + methods=['PUT'], + serializer_class=RecipeShoppingUpdateSerializer, + ) + def shopping(self, request, pk): + obj = self.get_object() + ingredients = request.data.get('ingredients', None) + servings = request.data.get('servings', obj.servings) + list_recipe = request.data.get('list_recipe', None) + content = {'msg': _(f'{obj.name} was added to the shopping list.')} + # TODO: Consider if this should be a Recipe method + ShoppingListEntry.list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=request.user) + return Response(content, status=status.HTTP_204_NO_CONTENT) + + @decorators.action( + detail=True, + methods=['GET'], + serializer_class=RecipeSimpleSerializer + ) + def related(self, request, pk): + obj = self.get_object() + if obj.get_space() != request.space: + raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403) + qs = obj.get_related_recipes(levels=2) # TODO: make levels a user setting, included in request data, keep solely in the backend? + return Response(self.serializer_class(qs, many=True).data) + + +# TODO deprecate class ShoppingListRecipeViewSet(viewsets.ModelViewSet): queryset = ShoppingListRecipe.objects serializer_class = ShoppingListRecipeSerializer permission_classes = [CustomIsOwner | CustomIsShared] def get_queryset(self): + self.queryset = self.queryset.filter(Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space)) return self.queryset.filter( - Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter( - shoppinglist__space=self.request.space).distinct().all() + Q(shoppinglist__created_by=self.request.user) + | Q(shoppinglist__shared=self.request.user) + | Q(entries__created_by=self.request.user) + | Q(entries__created_by__in=list(self.request.user.get_shopping_share())) + ).distinct().all() class ShoppingListEntryViewSet(viewsets.ModelViewSet): @@ -642,35 +696,46 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet): serializer_class = ShoppingListEntrySerializer permission_classes = [CustomIsOwner | CustomIsShared] query_params = [ - QueryParam(name='id', - description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), - qtype='int'), + QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'), QueryParam( name='checked', - description=_( - 'Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''recent'']
- ''recent'' includes unchecked items and recently completed items.') + description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''recent'']
- ''recent'' includes unchecked items and recently completed items.') ), - QueryParam(name='supermarket', - description=_('Returns the shopping list entries sorted by supermarket category order.'), - qtype='int'), + QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'), ] schema = QueryParamAutoSchema() def get_queryset(self): - return self.queryset.filter( - Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter( - shoppinglist__space=self.request.space).distinct().all() + self.queryset = self.queryset.filter(space=self.request.space) + + self.queryset = self.queryset.filter( + Q(created_by=self.request.user) + | Q(shoppinglist__shared=self.request.user) + | Q(created_by__in=list(self.request.user.get_shopping_share())) + ).distinct().all() + + if pk := self.request.query_params.getlist('id', []): + self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk]) + + if bool(int(self.request.query_params.get('recent', False))): + return shopping_helper(self.queryset, self.request) + + # TODO once old shopping list is removed this needs updated to sharing users in preferences + return self.queryset +# TODO deprecate class ShoppingListViewSet(viewsets.ModelViewSet): queryset = ShoppingList.objects serializer_class = ShoppingListSerializer permission_classes = [CustomIsOwner | CustomIsShared] + # TODO update to include settings shared user - make both work for a period of time def get_queryset(self): return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter( space=self.request.space).distinct() + # TODO deprecate def get_serializer_class(self): try: autosync = self.request.query_params.get('autosync', False) diff --git a/cookbook/views/data.py b/cookbook/views/data.py index 0900bb238..84d6fcede 100644 --- a/cookbook/views/data.py +++ b/cookbook/views/data.py @@ -22,8 +22,8 @@ from cookbook.helper.image_processing import handle_image from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.permission_helper import group_required, has_group_permission from cookbook.helper.recipe_url_import import parse_cooktime -from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe, - RecipeImport, Step, Sync, Unit, UserPreference) +from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe, RecipeImport, Step, Sync, + Unit, UserPreference) from cookbook.tables import SyncTable from recipes import settings @@ -111,8 +111,8 @@ def batch_edit(request): 'Batch edit done. %(count)d recipe was updated.', 'Batch edit done. %(count)d Recipes where updated.', count) % { - 'count': count, - } + 'count': count, + } messages.add_message(request, messages.SUCCESS, msg) return redirect('data_batch_edit') diff --git a/cookbook/views/lists.py b/cookbook/views/lists.py index c6a1fca25..6d8bfdf69 100644 --- a/cookbook/views/lists.py +++ b/cookbook/views/lists.py @@ -7,10 +7,9 @@ from django_tables2 import RequestConfig from cookbook.filters import ShoppingListFilter from cookbook.helper.permission_helper import group_required -from cookbook.models import (InviteLink, RecipeImport, - ShoppingList, Storage, SyncLog, UserFile) -from cookbook.tables import (ImportLogTable, InviteLinkTable, - RecipeImportTable, ShoppingListTable, StorageTable) +from cookbook.models import InviteLink, RecipeImport, ShoppingList, Storage, SyncLog, UserFile +from cookbook.tables import (ImportLogTable, InviteLinkTable, RecipeImportTable, ShoppingListTable, + StorageTable) @group_required('admin') @@ -40,20 +39,6 @@ def recipe_import(request): ) -# @group_required('user') -# def food(request): -# f = FoodFilter(request.GET, queryset=Food.objects.filter(space=request.space).all().order_by('pk')) - -# table = IngredientTable(f.qs) -# RequestConfig(request, paginate={'per_page': 25}).configure(table) - -# return render( -# request, -# 'generic/list_template.html', -# {'title': _("Ingredients"), 'table': table, 'filter': f} -# ) - - @group_required('user') def shopping_list(request): f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(space=request.space).filter( @@ -204,7 +189,7 @@ def automation(request): def user_file(request): try: current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[ - 'file_size_kb__sum'] / 1000 + 'file_size_kb__sum'] / 1000 except TypeError: current_file_size_mb = 0 @@ -244,11 +229,9 @@ def shopping_list_new(request): # model-name is the models.js name of the model, probably ALL-CAPS return render( request, - 'generic/checklist_template.html', + 'shoppinglist_template.html', { "title": _("New Shopping List"), - "config": { - 'model': "SHOPPING_LIST", # *REQUIRED* name of the model in models.js - } + } ) diff --git a/cookbook/views/views.py b/cookbook/views/views.py index 5b37cdc53..0eb2a4852 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -22,13 +22,13 @@ from django_tables2 import RequestConfig from rest_framework.authtoken.models import Token from cookbook.filters import RecipeFilter -from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm, - SpaceJoinForm, User, UserCreateForm, UserNameForm, UserPreference, - UserPreferenceForm) +from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingPreferenceForm, + SpaceCreateForm, SpaceJoinForm, SpacePreferenceForm, User, + UserCreateForm, UserNameForm, UserPreference, UserPreferenceForm) from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid -from cookbook.models import (Comment, CookLog, Food, InviteLink, Keyword, MealPlan, RecipeImport, - SearchFields, SearchPreference, ShareLink, ShoppingList, Space, Unit, - UserFile, ViewLog) +from cookbook.models import (Comment, CookLog, Food, FoodInheritField, InviteLink, Keyword, + MealPlan, RecipeImport, SearchFields, SearchPreference, ShareLink, + ShoppingList, Space, Unit, UserFile, ViewLog) from cookbook.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall, ViewLogTable) from cookbook.views.data import Object @@ -304,10 +304,6 @@ def user_settings(request): up.use_kj = form.cleaned_data['use_kj'] up.sticky_navbar = form.cleaned_data['sticky_navbar'] - up.shopping_auto_sync = form.cleaned_data['shopping_auto_sync'] - if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL: - up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL - up.save() elif 'user_name_form' in request.POST: @@ -378,10 +374,28 @@ def user_settings(request): sp.trigram_threshold = 0.1 sp.save() + elif 'shopping_form' in request.POST: + shopping_form = ShoppingPreferenceForm(request.POST, prefix='shopping') + if shopping_form.is_valid(): + if not up: + up = UserPreference(user=request.user) + + up.shopping_share.set(shopping_form.cleaned_data['shopping_share']) + up.mealplan_autoadd_shopping = shopping_form.cleaned_data['mealplan_autoadd_shopping'] + 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 + up.save() if up: - preference_form = UserPreferenceForm(instance=up, space=request.space) + preference_form = UserPreferenceForm(instance=up) + shopping_form = ShoppingPreferenceForm(instance=up) else: preference_form = UserPreferenceForm(space=request.space) + shopping_form = ShoppingPreferenceForm(space=request.space) fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len( sp.fulltext.all()) @@ -406,6 +420,7 @@ def user_settings(request): 'user_name_form': user_name_form, 'api_token': api_token, 'search_form': search_form, + 'shopping_form': shopping_form, 'active_tab': active_tab }) @@ -541,7 +556,22 @@ def space(request): InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=request.space).all()) RequestConfig(request, paginate={'per_page': 25}).configure(invite_links) - return render(request, 'space.html', {'space_users': space_users, 'counts': counts, 'invite_links': invite_links}) + space_form = SpacePreferenceForm(instance=request.space) + + space_form.base_fields['food_inherit'].queryset = Food.inherit_fields + if request.method == "POST" and 'space_form' in request.POST: + form = SpacePreferenceForm(request.POST, prefix='space') + if form.is_valid(): + request.space.food_inherit.set(form.cleaned_data['food_inherit']) + if form.cleaned_data['reset_food_inherit']: + Food.reset_inheritance(space=request.space) + + return render(request, 'space.html', { + 'space_users': space_users, + 'counts': counts, + 'invite_links': invite_links, + 'space_form': space_form + }) # TODO super hacky and quick solution, safe but needs rework diff --git a/recipes/middleware.py b/recipes/middleware.py index 5843f75bf..ebe9c51f0 100644 --- a/recipes/middleware.py +++ b/recipes/middleware.py @@ -61,9 +61,9 @@ def SqlPrintingMiddleware(get_response): sql = "\033[1;31m[%s]\033[0m %s" % (query['time'], nice_sql) total_time = total_time + float(query['time']) while len(sql) > width - indentation: - #print("%s%s" % (" " * indentation, sql[:width - indentation])) + # print("%s%s" % (" " * indentation, sql[:width - indentation])) sql = sql[width - indentation:] - #print("%s%s\n" % (" " * indentation, sql)) + # print("%s%s\n" % (" " * indentation, sql)) replace_tuple = (" " * indentation, str(total_time)) print("%s\033[1;32m[TOTAL TIME: %s seconds]\033[0m" % replace_tuple) print("%s\033[1;32m[TOTAL QUERIES: %s]\033[0m" % (" " * indentation, len(connection.queries))) diff --git a/vue/.gitignore b/vue/.gitignore deleted file mode 100644 index 403adbc1e..000000000 --- a/vue/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -.DS_Store -node_modules -/dist - - -# local env files -.env.local -.env.*.local - -# Log files -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Editor directories and files -.idea -.vscode -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/vue/.openapi-generator/FILES b/vue/.openapi-generator/FILES new file mode 100644 index 000000000..a80cd4f07 --- /dev/null +++ b/vue/.openapi-generator/FILES @@ -0,0 +1,8 @@ +.gitignore +.npmignore +api.ts +base.ts +common.ts +configuration.ts +git_push.sh +index.ts diff --git a/vue/.openapi-generator/VERSION b/vue/.openapi-generator/VERSION new file mode 100644 index 000000000..804440660 --- /dev/null +++ b/vue/.openapi-generator/VERSION @@ -0,0 +1 @@ +5.2.1 \ No newline at end of file diff --git a/vue/src/apps/ChecklistView/ChecklistView.vue b/vue/src/apps/ChecklistView/ChecklistView.vue deleted file mode 100644 index afbc241ab..000000000 --- a/vue/src/apps/ChecklistView/ChecklistView.vue +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - - diff --git a/vue/src/apps/ChecklistView/main.js b/vue/src/apps/ChecklistView/main.js deleted file mode 100644 index 355d8eae5..000000000 --- a/vue/src/apps/ChecklistView/main.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue' -import App from './ChecklistView' -import i18n from '@/i18n' - -Vue.config.productionTip = false - -// TODO move this and other default stuff to centralized JS file (verify nothing breaks) -let publicPath = localStorage.STATIC_URL + 'vue/' -if (process.env.NODE_ENV === 'development') { - publicPath = 'http://localhost:8080/' -} -export default __webpack_public_path__ = publicPath // eslint-disable-line - - -new Vue({ - i18n, - render: h => h(App), -}).$mount('#app') diff --git a/vue/src/apps/CookbookView/CookbookView.vue b/vue/src/apps/CookbookView/CookbookView.vue index d316a7224..4ee27f0eb 100644 --- a/vue/src/apps/CookbookView/CookbookView.vue +++ b/vue/src/apps/CookbookView/CookbookView.vue @@ -61,10 +61,10 @@ import Vue from 'vue' import {BootstrapVue} from 'bootstrap-vue' import 'bootstrap-vue/dist/bootstrap-vue.css' -import {ApiApiFactory} from "@/utils/openapi/api"; -import CookbookSlider from "@/components/CookbookSlider"; -import LoadingSpinner from "@/components/LoadingSpinner"; -import {StandardToasts} from "@/utils/utils"; +import {ApiApiFactory} from "@/utils/openapi/api.ts"; +import CookbookSlider from "../../components/CookbookSlider"; +import LoadingSpinner from "../../components/LoadingSpinner"; +import {StandardToasts} from "../../utils/utils"; Vue.use(BootstrapVue) diff --git a/vue/src/apps/MealPlanView/MealPlanView.vue b/vue/src/apps/MealPlanView/MealPlanView.vue index 7d451edf4..d26804570 100644 --- a/vue/src/apps/MealPlanView/MealPlanView.vue +++ b/vue/src/apps/MealPlanView/MealPlanView.vue @@ -1,643 +1,670 @@ @@ -656,28 +683,28 @@ export default { } .calender-parent { - display: flex; - flex-direction: column; - flex-grow: 1; - overflow-x: hidden; - overflow-y: hidden; - height: 70vh; + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-x: hidden; + overflow-y: hidden; + height: 70vh; } .cv-item { - white-space: inherit !important; + white-space: inherit !important; } .isHovered { - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; } .cv-day.draghover { - box-shadow: inset 0 0 0.2em 0.2em rgb(221, 191, 134) !important; + box-shadow: inset 0 0 0.2em 0.2em rgb(221, 191, 134) !important; } .modal-backdrop { - opacity: 0.5; + opacity: 0.5; } /* @@ -693,84 +720,84 @@ having to override as much. .theme-default .cv-header, .theme-default .cv-header-day { - background-color: #f0f0f0; + background-color: #f0f0f0; } .theme-default .cv-header .periodLabel { - font-size: 1.5em; + font-size: 1.5em; } /* Grid */ .theme-default .cv-weeknumber { - background-color: #e0e0e0; - border-color: #ccc; - color: #808080; + background-color: #e0e0e0; + border-color: #ccc; + color: #808080; } .theme-default .cv-weeknumber span { - margin: 0; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + margin: 0; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } .theme-default .cv-day.past { - background-color: #fafafa; + background-color: #fafafa; } .theme-default .cv-day.outsideOfMonth { - background-color: #f7f7f7; + background-color: #f7f7f7; } .theme-default .cv-day.today { - background-color: #ffe; + background-color: #ffe; } .theme-default .cv-day[aria-selected] { - background-color: #ffc; + background-color: #ffc; } /* Events */ .theme-default .cv-item { - border-color: #e0e0f0; - border-radius: 0.5em; - background-color: #fff; - text-overflow: ellipsis; + border-color: #e0e0f0; + border-radius: 0.5em; + background-color: #fff; + text-overflow: ellipsis; } .theme-default .cv-item.purple { - background-color: #f0e0ff; - border-color: #e7d7f7; + background-color: #f0e0ff; + border-color: #e7d7f7; } .theme-default .cv-item.orange { - background-color: #ffe7d0; - border-color: #f7e0c7; + background-color: #ffe7d0; + border-color: #f7e0c7; } .theme-default .cv-item.continued::before, .theme-default .cv-item.toBeContinued::after { - content: " \21e2 "; - color: #999; + content: " \21e2 "; + color: #999; } .theme-default .cv-item.toBeContinued { - border-right-style: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; + border-right-style: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; } .theme-default .cv-item.isHovered.hasUrl { - text-decoration: underline; + text-decoration: underline; } .theme-default .cv-item.continued { - border-left-style: none; - border-top-left-radius: 0; - border-bottom-left-radius: 0; + border-left-style: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; } .cv-item.span3, @@ -778,20 +805,20 @@ having to override as much. .cv-item.span5, .cv-item.span6, .cv-item.span7 { - text-align: center; + text-align: center; } /* Event Times */ .theme-default .cv-item .startTime, .theme-default .cv-item .endTime { - font-weight: bold; - color: #666; + font-weight: bold; + color: #666; } /* Drag and drop */ .theme-default .cv-day.draghover { - box-shadow: inset 0 0 0.2em 0.2em yellow; + box-shadow: inset 0 0 0.2em 0.2em yellow; } - \ No newline at end of file + diff --git a/vue/src/apps/ModelListView/ModelListView.vue b/vue/src/apps/ModelListView/ModelListView.vue index 2d79c865a..c8a6bec72 100644 --- a/vue/src/apps/ModelListView/ModelListView.vue +++ b/vue/src/apps/ModelListView/ModelListView.vue @@ -25,14 +25,7 @@
- + {{ $t("show_split_screen") }}
@@ -42,46 +35,19 @@
- +
- +
@@ -98,13 +64,12 @@ import { BootstrapVue } from "bootstrap-vue" import "bootstrap-vue/dist/bootstrap-vue.css" -import { CardMixin, ApiMixin, getConfig } from "@/utils/utils" -import { StandardToasts, ToastMixin } from "@/utils/utils" +import { CardMixin, ApiMixin, getConfig, StandardToasts, getUserPreference, makeToast } from "@/utils/utils" import GenericInfiniteCards from "@/components/GenericInfiniteCards" import GenericHorizontalCard from "@/components/GenericHorizontalCard" import GenericModalForm from "@/components/Modals/GenericModalForm" -import ModelMenu from "@/components/ModelMenu" +import ModelMenu from "@/components/ContextMenu/ModelMenu" import { ApiApiFactory } from "@/utils/openapi/api" //import StorageQuota from "@/components/StorageQuota"; @@ -114,13 +79,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: "ModelListView", - mixins: [CardMixin, ApiMixin, ToastMixin], - components: { - GenericHorizontalCard, - GenericModalForm, - GenericInfiniteCards, - ModelMenu, - }, + mixins: [CardMixin, ApiMixin], + components: { GenericHorizontalCard, GenericModalForm, GenericInfiniteCards, ModelMenu }, data() { return { // this.Models and this.Actions inherited from ApiMixin @@ -236,6 +196,7 @@ export default { } }, finishAction: function (e) { + let update = undefined switch (e?.action) { case "save": this.saveThis(e.form_data) @@ -263,7 +224,7 @@ export default { } this.clearState() }, - getItems: function (params, col) { + getItems: function (params = {}, col) { let column = col || "left" params.options = { query: { extended: 1 } } // returns extended values in API response this.genericAPI(this.this_model, this.Actions.LIST, params) diff --git a/vue/src/apps/RecipeEditView/RecipeEditView.vue b/vue/src/apps/RecipeEditView/RecipeEditView.vue index 61cad5e0b..3b98745ee 100644 --- a/vue/src/apps/RecipeEditView/RecipeEditView.vue +++ b/vue/src/apps/RecipeEditView/RecipeEditView.vue @@ -629,7 +629,6 @@ export default { apiFactory.updateRecipe(this.recipe_id, this.recipe, {}).then((response) => { - console.log(response) StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE) this.recipe_changed = false if (view_after) { diff --git a/vue/src/apps/RecipeView/RecipeView.vue b/vue/src/apps/RecipeView/RecipeView.vue index f0343fde4..de938729b 100644 --- a/vue/src/apps/RecipeView/RecipeView.vue +++ b/vue/src/apps/RecipeView/RecipeView.vue @@ -1,90 +1,61 @@
-
-
+
-
-
-
+
+
+ +
- +
+
+
+ +
+
+ +
+
+ +
+
+
-
-
-
- + + +
+
-
+ -
- - - - -
- -
- - - - - - -
- + diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue new file mode 100644 index 000000000..3e93d30bc --- /dev/null +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -0,0 +1,805 @@ + + + + + + + diff --git a/vue/src/apps/ShoppingListView/main.js b/vue/src/apps/ShoppingListView/main.js new file mode 100644 index 000000000..dda3184c3 --- /dev/null +++ b/vue/src/apps/ShoppingListView/main.js @@ -0,0 +1,10 @@ +import i18n from "@/i18n" +import Vue from "vue" +import App from "./ShoppingListView" + +Vue.config.productionTip = false + +new Vue({ + i18n, + render: (h) => h(App), +}).$mount("#app") diff --git a/vue/src/components/Badges.vue b/vue/src/components/Badges.vue index 2664edf09..f35999480 100644 --- a/vue/src/components/Badges.vue +++ b/vue/src/components/Badges.vue @@ -4,16 +4,22 @@ :item="item"/> + + \ No newline at end of file diff --git a/vue/src/components/Badges/Shopping.vue b/vue/src/components/Badges/Shopping.vue new file mode 100644 index 000000000..9c2b85765 --- /dev/null +++ b/vue/src/components/Badges/Shopping.vue @@ -0,0 +1,94 @@ + + + \ No newline at end of file diff --git a/vue/src/components/ContextMenu/ContextMenu.vue b/vue/src/components/ContextMenu/ContextMenu.vue index 73381f772..12c9efe19 100644 --- a/vue/src/components/ContextMenu/ContextMenu.vue +++ b/vue/src/components/ContextMenu/ContextMenu.vue @@ -1,127 +1,118 @@ diff --git a/vue/src/components/ContextMenu/ContextMenuItem.vue b/vue/src/components/ContextMenu/ContextMenuItem.vue index 660c6bb6f..43ce98160 100644 --- a/vue/src/components/ContextMenu/ContextMenuItem.vue +++ b/vue/src/components/ContextMenu/ContextMenuItem.vue @@ -1,16 +1,13 @@ - - + diff --git a/vue/src/components/ContextMenu/ContextMenuSubmenu.vue b/vue/src/components/ContextMenu/ContextMenuSubmenu.vue new file mode 100644 index 000000000..4ff781f4b --- /dev/null +++ b/vue/src/components/ContextMenu/ContextMenuSubmenu.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/vue/src/components/GenericContextMenu.vue b/vue/src/components/ContextMenu/GenericContextMenu.vue similarity index 100% rename from vue/src/components/GenericContextMenu.vue rename to vue/src/components/ContextMenu/GenericContextMenu.vue diff --git a/vue/src/components/ModelMenu.vue b/vue/src/components/ContextMenu/ModelMenu.vue similarity index 100% rename from vue/src/components/ModelMenu.vue rename to vue/src/components/ContextMenu/ModelMenu.vue diff --git a/vue/src/components/RecipeContextMenu.vue b/vue/src/components/ContextMenu/RecipeContextMenu.vue similarity index 89% rename from vue/src/components/RecipeContextMenu.vue rename to vue/src/components/ContextMenu/RecipeContextMenu.vue index 3aad77387..f4b3067e7 100644 --- a/vue/src/components/RecipeContextMenu.vue +++ b/vue/src/components/ContextMenu/RecipeContextMenu.vue @@ -26,7 +26,11 @@ {{ $t('Add_to_Shopping') }} - + New {{ $t('Add_to_Shopping') }} + + + {{ $t('Add_to_Plan') }} @@ -76,6 +80,7 @@ +
@@ -84,8 +89,9 @@ import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils"; import CookLog from "@/components/CookLog"; import axios from "axios"; -import AddRecipeToBook from "./AddRecipeToBook"; -import MealPlanEditModal from "@/components/MealPlanEditModal"; +import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"; +import MealPlanEditModal from "@/components/Modals/MealPlanEditModal"; +import ShoppingModal from "@/components/Modals/ShoppingModal"; import moment from "moment"; import Vue from "vue"; import {ApiApiFactory} from "@/utils/openapi/api"; @@ -100,7 +106,8 @@ export default { components: { AddRecipeToBook, CookLog, - MealPlanEditModal + MealPlanEditModal, + ShoppingModal }, data() { return { @@ -118,7 +125,7 @@ export default { servings: 1, shared: [], title: '', - title_placeholder: this.$t('Title') + title_placeholder: this.$t('Title'), } }, entryEditing: {}, @@ -177,7 +184,10 @@ export default { url: this.recipe_share_link } navigator.share(shareData) - } + }, + addToShopping() { + this.$bvModal.show(`shopping_${this.modal_id}`) + }, } } diff --git a/vue/src/components/GenericHorizontalCard.vue b/vue/src/components/GenericHorizontalCard.vue index 232c12ebd..fcfe8094f 100644 --- a/vue/src/components/GenericHorizontalCard.vue +++ b/vue/src/components/GenericHorizontalCard.vue @@ -92,13 +92,13 @@ {{$t('Cancel')}} - + diff --git a/vue/src/components/IngredientsCard.vue b/vue/src/components/IngredientsCard.vue new file mode 100644 index 000000000..8dc2c272b --- /dev/null +++ b/vue/src/components/IngredientsCard.vue @@ -0,0 +1,187 @@ + + + diff --git a/vue/src/components/MealPlanEditModal.vue b/vue/src/components/MealPlanEditModal.vue deleted file mode 100644 index ee74b80c7..000000000 --- a/vue/src/components/MealPlanEditModal.vue +++ /dev/null @@ -1,216 +0,0 @@ - - - - - \ No newline at end of file diff --git a/vue/src/components/AddRecipeToBook.vue b/vue/src/components/Modals/AddRecipeToBook.vue similarity index 100% rename from vue/src/components/AddRecipeToBook.vue rename to vue/src/components/Modals/AddRecipeToBook.vue diff --git a/vue/src/components/Modals/GenericModalForm.vue b/vue/src/components/Modals/GenericModalForm.vue index 237fd903c..1e2d22379 100644 --- a/vue/src/components/Modals/GenericModalForm.vue +++ b/vue/src/components/Modals/GenericModalForm.vue @@ -28,7 +28,7 @@ + + diff --git a/vue/src/components/Modals/ShoppingModal.vue b/vue/src/components/Modals/ShoppingModal.vue new file mode 100644 index 000000000..be0089208 --- /dev/null +++ b/vue/src/components/Modals/ShoppingModal.vue @@ -0,0 +1,158 @@ + + + \ No newline at end of file diff --git a/vue/src/components/RecipeCard.vue b/vue/src/components/RecipeCard.vue index 0ebae44b9..1728670d5 100755 --- a/vue/src/components/RecipeCard.vue +++ b/vue/src/components/RecipeCard.vue @@ -1,158 +1,137 @@ - - - - - - - {{ footer_text }} - - - + {{ footer_text }} + diff --git a/vue/src/components/ShoppingLineItem.vue b/vue/src/components/ShoppingLineItem.vue new file mode 100644 index 000000000..30a3a5d12 --- /dev/null +++ b/vue/src/components/ShoppingLineItem.vue @@ -0,0 +1,269 @@ + + + + + + + diff --git a/vue/src/components/StepComponent.vue b/vue/src/components/StepComponent.vue index 403b58299..83976ac56 100644 --- a/vue/src/components/StepComponent.vue +++ b/vue/src/components/StepComponent.vue @@ -38,12 +38,11 @@
- - - +
@@ -161,6 +160,7 @@ import {calculateAmount} from "@/utils/utils"; import {GettextMixin} from "@/utils/utils"; import CompileComponent from "@/components/CompileComponent"; +import IngredientsCard from "@/components/IngredientsCard"; import Vue from "vue"; import moment from "moment"; import {ResolveUrlMixin} from "@/utils/utils"; @@ -174,10 +174,7 @@ export default { GettextMixin, ResolveUrlMixin, ], - components: { - IngredientComponent, - CompileComponent, - }, + components: { CompileComponent, IngredientsCard}, props: { step: Object, ingredient_factor: Number, diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index 589ee6fa2..3a220d518 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -173,6 +173,15 @@ "Time": "Time", "Text": "Text", "Shopping_list": "Shopping List", + "Added_by": "Added By", + "Added_on": "Added On", + "AddToShopping": "Add to shopping list", + "IngredientInShopping": "This ingredient is in your shopping list.", + "NotInShopping": "{food} is not in your shopping list.", + "OnHand": "Have On Hand", + "FoodOnHand": "You have {food} on hand.", + "FoodNotOnHand": "You do not have {food} on hand.", + "Undefined": "Undefined", "Create_Meal_Plan_Entry": "Create meal plan entry", "Edit_Meal_Plan_Entry": "Edit meal plan entry", "Title": "Title", @@ -194,6 +203,41 @@ "Title_or_Recipe_Required": "Title or recipe selection required", "Color": "Color", "New_Meal_Type": "New Meal type", + "AddFoodToShopping": "Add {food} to your shopping list", + "RemoveFoodFromShopping": "Remove {food} from your shopping list", + "DeleteShoppingConfirm": "Are you sure that you want to remove all {food} from the shopping list?", + "IgnoredFood": "{food} is set to ignore shopping.", + "Add_Servings_to_Shopping": "Add {servings} Servings to Shopping", + "Inherit": "Inherit", + "IgnoreInherit": "Do Not Inherit Fields", + "FoodInherit": "Food Inheritable Fields", + "ShowUncategorizedFood": "Show Undefined", + "GroupBy": "Group By", + "SupermarketCategoriesOnly": "Supermarket Categories Only", + "MoveCategory": "Move To: ", + "CountMore": "...+{count} more", + "IgnoreThis": "Never auto-add {food} to shopping", + "DelayFor": "Delay for {hours} hours", + "Warning": "Warning", + "NoCategory": "No category selected.", + "InheritWarning": "{food} is set to inherit, changes may not persist.", + "ShowDelayed": "Show Delayed Items", + "Completed": "Completed", + "OfflineAlert": "You are offline, shopping list may not syncronize.", + "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": "Add Related Recipes", + "default_delay": "Default Delay Hours", + "shopping_share_desc": "Users will see all items you add to your shopping list. They must add you to see items on their list.", + "shopping_auto_sync_desc": "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 will use mobile data.", + "mealplan_autoadd_shopping_desc": "Automatically add meal plan ingredients to shopping list.", + "mealplan_autoexclude_onhand_desc": "When automatically adding a meal plan to the shopping list, exclude ingredients that are on hand.", + "mealplan_autoinclude_related_desc": "When automatically adding a meal plan to the shopping list, include all related recipes.", + "default_delay_desc": "Default number of hours to delay a shopping list entry.", + "filter_to_supermarket": "Filter to Supermarket", + "filter_to_supermarket_desc": "Filter shopping list to only include supermarket categories.", "Week_Numbers": "Week numbers", "Show_Week_Numbers": "Show week numbers ?", "Export_As_ICal": "Export current period to iCal format", diff --git a/vue/src/utils/api.js b/vue/src/utils/api.js index ba1648188..8d35049dc 100644 --- a/vue/src/utils/api.js +++ b/vue/src/utils/api.js @@ -1,6 +1,7 @@ import axios from "axios"; import {djangoGettext as _, makeToast} from "@/utils/utils"; import {resolveDjangoUrl} from "@/utils/utils"; +import {ApiApiFactory} from "@/utils/openapi/api.ts"; axios.defaults.xsrfCookieName = 'csrftoken' axios.defaults.xsrfHeaderName = "X-CSRFTOKEN" @@ -47,4 +48,8 @@ function handleError(error, message) { makeToast('Error', message, 'danger') console.log(error) } -} \ No newline at end of file +} + +/* +* Generic class to use OpenAPIs with parameters and provide generic modals +* */ \ No newline at end of file diff --git a/vue/src/utils/apiv2.js b/vue/src/utils/apiv2.js new file mode 100644 index 000000000..540890a6f --- /dev/null +++ b/vue/src/utils/apiv2.js @@ -0,0 +1,16 @@ +/* +* Utility functions to use OpenAPIs generically +* */ +import {ApiApiFactory} from "@/utils/openapi/api.ts"; + +import axios from "axios"; +axios.defaults.xsrfCookieName = 'csrftoken' +axios.defaults.xsrfHeaderName = "X-CSRFTOKEN" + +export class GenericAPI { + constructor(model, action) { + this.model = model; + this.action = action; + this.function_name = action + model + } +} \ No newline at end of file diff --git a/vue/src/utils/models.js b/vue/src/utils/models.js index 23911b3d9..312e6cc91 100644 --- a/vue/src/utils/models.js +++ b/vue/src/utils/models.js @@ -67,12 +67,15 @@ export class Models { merge: true, badges: { linked_recipe: true, + on_hand: true, + shopping: true, }, tags: [{ field: "supermarket_category", label: "name", color: "info" }], // REQUIRED: unordered array of fields that can be set during create create: { // if not defined partialUpdate will use the same parameters, prepending 'id' - params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category"]], + params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category", "on_hand", "inherit", "ignore_inherit"]], + form: { name: { form_field: true, @@ -101,6 +104,12 @@ export class Models { field: "ignore_shopping", label: i18n.t("Ignore_Shopping"), }, + onhand: { + form_field: true, + type: "checkbox", + field: "on_hand", + label: i18n.t("OnHand"), + }, shopping_category: { form_field: true, type: "lookup", @@ -109,8 +118,30 @@ export class Models { label: i18n.t("Shopping_Category"), allow_create: true, }, + inherit: { + form_field: true, + type: "checkbox", + field: "inherit", + label: i18n.t("Inherit"), + }, + ignore_inherit: { + form_field: true, + type: "lookup", + multiple: true, + field: "ignore_inherit", + list: "FOOD_INHERIT_FIELDS", + label: i18n.t("IgnoreInherit"), + }, + form_function: "FoodCreateDefault", }, }, + shopping: { + params: ["id", ["id", "amount", "unit", "_delete"]], + }, + } + static FOOD_INHERIT_FIELDS = { + name: i18n.t("FoodInherit"), + apiName: "FoodInheritField", } static KEYWORD = { @@ -180,6 +211,12 @@ export class Models { static SHOPPING_LIST = { name: i18n.t("Shopping_list"), apiName: "ShoppingListEntry", + list: { + params: ["id", "checked", "supermarket", "options"], + }, + create: { + params: [["amount", "unit", "food", "checked"]], + }, } static RECIPE_BOOK = { @@ -370,41 +407,15 @@ export class Models { name: i18n.t("Recipe"), apiName: "Recipe", list: { - params: [ - "query", - "keywords", - "foods", - "units", - "rating", - "books", - "steps", - "keywordsOr", - "foodsOr", - "booksOr", - "internal", - "random", - "_new", - "page", - "pageSize", - "options", - ], - config: { - foods: { type: "string" }, - keywords: { type: "string" }, - books: { type: "string" }, - }, + params: ["query", "keywords", "foods", "units", "rating", "books", "keywordsOr", "foodsOr", "booksOr", "internal", "random", "_new", "page", "pageSize", "options"], + // 'config': { + // 'foods': {'type': 'string'}, + // 'keywords': {'type': 'string'}, + // 'books': {'type': 'string'}, + // } }, - } - - static STEP = { - name: i18n.t("Step"), - apiName: "Step", - paginated: true, - list: { - header_component: { - name: "BetaWarning", - }, - params: ["query", "page", "pageSize", "options"], + shopping: { + params: ["id", ["id", "list_recipe", "ingredients", "servings"]], }, } @@ -461,6 +472,11 @@ export class Models { }, }, } + static USER = { + name: i18n.t("User"), + apiName: "User", + paginated: false, + } } export class Actions { @@ -639,4 +655,7 @@ export class Actions { }, }, } + static SHOPPING = { + function: "shopping", + } } diff --git a/vue/src/utils/openapi/api.ts b/vue/src/utils/openapi/api.ts index 5d4aabc55..3c20e90c4 100644 --- a/vue/src/utils/openapi/api.ts +++ b/vue/src/utils/openapi/api.ts @@ -197,6 +197,12 @@ export interface Food { * @memberof Food */ description?: string; + /** + * + * @type {string} + * @memberof Food + */ + shopping?: string; /** * * @type {FoodRecipe} @@ -227,6 +233,74 @@ export interface Food { * @memberof Food */ numchild?: number; + /** + * + * @type {boolean} + * @memberof Food + */ + on_hand?: boolean; + /** + * + * @type {boolean} + * @memberof Food + */ + inherit?: boolean; + /** + * + * @type {Array} + * @memberof Food + */ + ignore_inherit?: Array | null; +} +/** + * + * @export + * @interface FoodIgnoreInherit + */ +export interface FoodIgnoreInherit { + /** + * + * @type {number} + * @memberof FoodIgnoreInherit + */ + id?: number; + /** + * + * @type {string} + * @memberof FoodIgnoreInherit + */ + name?: string; + /** + * + * @type {string} + * @memberof FoodIgnoreInherit + */ + field?: string; +} +/** + * + * @export + * @interface FoodInheritField + */ +export interface FoodInheritField { + /** + * + * @type {number} + * @memberof FoodInheritField + */ + id?: number; + /** + * + * @type {string} + * @memberof FoodInheritField + */ + name?: string; + /** + * + * @type {string} + * @memberof FoodInheritField + */ + field?: string; } /** * @@ -253,6 +327,46 @@ export interface FoodRecipe { */ url?: string; } +/** + * + * @export + * @interface FoodShoppingUpdate + */ +export interface FoodShoppingUpdate { + /** + * + * @type {number} + * @memberof FoodShoppingUpdate + */ + id?: number; + /** + * Amount of food to add to the shopping list + * @type {number} + * @memberof FoodShoppingUpdate + */ + amount?: number | null; + /** + * ID of unit to use for the shopping list + * @type {number} + * @memberof FoodShoppingUpdate + */ + unit?: number | null; + /** + * When set to true will delete all food from active shopping lists. + * @type {string} + * @memberof FoodShoppingUpdate + */ + _delete: FoodShoppingUpdateDeleteEnum; +} + +/** + * @export + * @enum {string} + */ +export enum FoodShoppingUpdateDeleteEnum { + True = 'true' +} + /** * * @export @@ -387,18 +501,6 @@ export interface ImportLogKeyword { * @memberof ImportLogKeyword */ numchild?: number; - /** - * - * @type {string} - * @memberof ImportLogKeyword - */ - created_at?: string; - /** - * - * @type {string} - * @memberof ImportLogKeyword - */ - updated_at?: string; } /** * @@ -414,10 +516,10 @@ export interface Ingredient { id?: number; /** * - * @type {StepFood} + * @type {IngredientFood} * @memberof Ingredient */ - food: StepFood | null; + food: IngredientFood | null; /** * * @type {FoodSupermarketCategory} @@ -455,6 +557,85 @@ export interface Ingredient { */ no_amount?: boolean; } +/** + * + * @export + * @interface IngredientFood + */ +export interface IngredientFood { + /** + * + * @type {number} + * @memberof IngredientFood + */ + id?: number; + /** + * + * @type {string} + * @memberof IngredientFood + */ + name: string; + /** + * + * @type {string} + * @memberof IngredientFood + */ + description?: string; + /** + * + * @type {string} + * @memberof IngredientFood + */ + shopping?: string; + /** + * + * @type {FoodRecipe} + * @memberof IngredientFood + */ + recipe?: FoodRecipe | null; + /** + * + * @type {boolean} + * @memberof IngredientFood + */ + ignore_shopping?: boolean; + /** + * + * @type {FoodSupermarketCategory} + * @memberof IngredientFood + */ + supermarket_category?: FoodSupermarketCategory | null; + /** + * + * @type {string} + * @memberof IngredientFood + */ + parent?: string; + /** + * + * @type {number} + * @memberof IngredientFood + */ + numchild?: number; + /** + * + * @type {boolean} + * @memberof IngredientFood + */ + on_hand?: boolean; + /** + * + * @type {boolean} + * @memberof IngredientFood + */ + inherit?: boolean; + /** + * + * @type {Array} + * @memberof IngredientFood + */ + ignore_inherit?: Array | null; +} /** * * @export @@ -481,10 +662,10 @@ export interface InlineResponse200 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse200 */ - results?: Array; + results?: Array; } /** * @@ -512,10 +693,10 @@ export interface InlineResponse2001 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2001 */ - results?: Array; + results?: Array; } /** * @@ -543,10 +724,10 @@ export interface InlineResponse2002 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2002 */ - results?: Array; + results?: Array; } /** * @@ -574,10 +755,10 @@ export interface InlineResponse2003 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2003 */ - results?: Array; + results?: Array; } /** * @@ -636,10 +817,10 @@ export interface InlineResponse2005 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2005 */ - results?: Array; + results?: Array; } /** * @@ -667,10 +848,10 @@ export interface InlineResponse2006 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2006 */ - results?: Array; + results?: Array; } /** * @@ -698,10 +879,10 @@ export interface InlineResponse2007 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2007 */ - results?: Array; + results?: Array; } /** * @@ -729,10 +910,10 @@ export interface InlineResponse2008 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2008 */ - results?: Array; + results?: Array; } /** * @@ -760,10 +941,10 @@ export interface InlineResponse2009 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2009 */ - results?: Array; + results?: Array; } /** * @@ -813,18 +994,6 @@ export interface Keyword { * @memberof Keyword */ numchild?: number; - /** - * - * @type {string} - * @memberof Keyword - */ - created_at?: string; - /** - * - * @type {string} - * @memberof Keyword - */ - updated_at?: string; } /** * @@ -986,10 +1155,10 @@ export interface MealPlanRecipe { image?: any; /** * - * @type {Array} + * @type {Array} * @memberof MealPlanRecipe */ - keywords: Array; + keywords: Array; /** * * @type {number} @@ -1057,6 +1226,25 @@ export interface MealPlanRecipe { */ _new?: string; } +/** + * + * @export + * @interface MealPlanRecipeKeywords + */ +export interface MealPlanRecipeKeywords { + /** + * + * @type {number} + * @memberof MealPlanRecipeKeywords + */ + id?: number; + /** + * + * @type {string} + * @memberof MealPlanRecipeKeywords + */ + label?: string; +} /** * * @export @@ -1253,10 +1441,10 @@ export interface RecipeBook { icon?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof RecipeBook */ - shared: Array; + shared: Array; /** * * @type {string} @@ -1301,6 +1489,50 @@ export interface RecipeBookEntry { */ recipe_content?: string; } +/** + * + * @export + * @interface RecipeBookShared + */ +export interface RecipeBookShared { + /** + * + * @type {number} + * @memberof RecipeBookShared + */ + id?: number; + /** + * + * @type {string} + * @memberof RecipeBookShared + */ + username?: string; +} +/** + * + * @export + * @interface RecipeFile + */ +export interface RecipeFile { + /** + * + * @type {string} + * @memberof RecipeFile + */ + name: string; + /** + * + * @type {any} + * @memberof RecipeFile + */ + file?: any; + /** + * + * @type {number} + * @memberof RecipeFile + */ + id?: number; +} /** * * @export @@ -1314,6 +1546,61 @@ export interface RecipeImage { */ image?: any | null; } +/** + * + * @export + * @interface RecipeIngredients + */ +export interface RecipeIngredients { + /** + * + * @type {number} + * @memberof RecipeIngredients + */ + id?: number; + /** + * + * @type {IngredientFood} + * @memberof RecipeIngredients + */ + food: IngredientFood | null; + /** + * + * @type {FoodSupermarketCategory} + * @memberof RecipeIngredients + */ + unit: FoodSupermarketCategory | null; + /** + * + * @type {string} + * @memberof RecipeIngredients + */ + amount: string; + /** + * + * @type {string} + * @memberof RecipeIngredients + */ + note?: string | null; + /** + * + * @type {number} + * @memberof RecipeIngredients + */ + order?: number; + /** + * + * @type {boolean} + * @memberof RecipeIngredients + */ + is_header?: boolean; + /** + * + * @type {boolean} + * @memberof RecipeIngredients + */ + no_amount?: boolean; +} /** * * @export @@ -1362,18 +1649,6 @@ export interface RecipeKeywords { * @memberof RecipeKeywords */ numchild?: number; - /** - * - * @type {string} - * @memberof RecipeKeywords - */ - created_at?: string; - /** - * - * @type {string} - * @memberof RecipeKeywords - */ - updated_at?: string; } /** * @@ -1450,10 +1725,10 @@ export interface RecipeOverview { image?: any; /** * - * @type {Array} + * @type {Array} * @memberof RecipeOverview */ - keywords: Array; + keywords: Array; /** * * @type {number} @@ -1524,21 +1799,58 @@ export interface RecipeOverview { /** * * @export - * @interface RecipeOverviewKeywords + * @interface RecipeShoppingUpdate */ -export interface RecipeOverviewKeywords { +export interface RecipeShoppingUpdate { /** * * @type {number} - * @memberof RecipeOverviewKeywords + * @memberof RecipeShoppingUpdate + */ + id?: number; + /** + * Existing shopping list to update + * @type {number} + * @memberof RecipeShoppingUpdate + */ + list_recipe?: number | null; + /** + * List of ingredient IDs from the recipe to add, if not provided all ingredients will be added. + * @type {number} + * @memberof RecipeShoppingUpdate + */ + ingredients?: number | null; + /** + * Providing a list_recipe ID and servings of 0 will delete that shopping list. + * @type {number} + * @memberof RecipeShoppingUpdate + */ + servings?: number | null; +} +/** + * + * @export + * @interface RecipeSimple + */ +export interface RecipeSimple { + /** + * + * @type {number} + * @memberof RecipeSimple */ id?: number; /** * * @type {string} - * @memberof RecipeOverviewKeywords + * @memberof RecipeSimple */ - label?: string; + name?: string; + /** + * + * @type {string} + * @memberof RecipeSimple + */ + url?: string; } /** * @@ -1572,10 +1884,10 @@ export interface RecipeSteps { instruction?: string; /** * - * @type {Array} + * @type {Array} * @memberof RecipeSteps */ - ingredients: Array; + ingredients: Array; /** * * @type {string} @@ -1608,10 +1920,10 @@ export interface RecipeSteps { show_as_header?: boolean; /** * - * @type {StepFile} + * @type {RecipeFile} * @memberof RecipeSteps */ - file?: StepFile | null; + file?: RecipeFile | null; /** * * @type {number} @@ -1681,10 +1993,10 @@ export interface ShoppingList { entries: Array | null; /** * - * @type {Array} + * @type {Array} * @memberof ShoppingList */ - shared: Array; + shared: Array; /** * * @type {boolean} @@ -1710,6 +2022,25 @@ export interface ShoppingList { */ created_at?: string; } +/** + * + * @export + * @interface ShoppingListCreatedBy + */ +export interface ShoppingListCreatedBy { + /** + * + * @type {number} + * @memberof ShoppingListCreatedBy + */ + id?: number; + /** + * + * @type {string} + * @memberof ShoppingListCreatedBy + */ + username?: string; +} /** * * @export @@ -1724,22 +2055,34 @@ export interface ShoppingListEntries { id?: number; /** * - * @type {number} + * @type {string} * @memberof ShoppingListEntries */ - list_recipe?: number | null; + list_recipe?: string; /** * - * @type {StepFood} + * @type {IngredientFood} * @memberof ShoppingListEntries */ - food: StepFood | null; + food: IngredientFood | null; /** * * @type {FoodSupermarketCategory} * @memberof ShoppingListEntries */ unit?: FoodSupermarketCategory | null; + /** + * + * @type {number} + * @memberof ShoppingListEntries + */ + ingredient?: number | null; + /** + * + * @type {string} + * @memberof ShoppingListEntries + */ + ingredient_note?: string; /** * * @type {string} @@ -1758,6 +2101,30 @@ export interface ShoppingListEntries { * @memberof ShoppingListEntries */ checked?: boolean; + /** + * + * @type {ShoppingListRecipeMealplan} + * @memberof ShoppingListEntries + */ + recipe_mealplan?: ShoppingListRecipeMealplan; + /** + * + * @type {ShoppingListCreatedBy} + * @memberof ShoppingListEntries + */ + created_by?: ShoppingListCreatedBy; + /** + * + * @type {string} + * @memberof ShoppingListEntries + */ + created_at?: string; + /** + * + * @type {string} + * @memberof ShoppingListEntries + */ + completed_at?: string; } /** * @@ -1773,22 +2140,34 @@ export interface ShoppingListEntry { id?: number; /** * - * @type {number} + * @type {string} * @memberof ShoppingListEntry */ - list_recipe?: number | null; + list_recipe?: string; /** * - * @type {StepFood} + * @type {IngredientFood} * @memberof ShoppingListEntry */ - food: StepFood | null; + food: IngredientFood | null; /** * * @type {FoodSupermarketCategory} * @memberof ShoppingListEntry */ unit?: FoodSupermarketCategory | null; + /** + * + * @type {number} + * @memberof ShoppingListEntry + */ + ingredient?: number | null; + /** + * + * @type {string} + * @memberof ShoppingListEntry + */ + ingredient_note?: string; /** * * @type {string} @@ -1807,6 +2186,30 @@ export interface ShoppingListEntry { * @memberof ShoppingListEntry */ checked?: boolean; + /** + * + * @type {ShoppingListRecipeMealplan} + * @memberof ShoppingListEntry + */ + recipe_mealplan?: ShoppingListRecipeMealplan; + /** + * + * @type {ShoppingListCreatedBy} + * @memberof ShoppingListEntry + */ + created_by?: ShoppingListCreatedBy; + /** + * + * @type {string} + * @memberof ShoppingListEntry + */ + created_at?: string; + /** + * + * @type {string} + * @memberof ShoppingListEntry + */ + completed_at?: string; } /** * @@ -1820,6 +2223,12 @@ export interface ShoppingListRecipe { * @memberof ShoppingListRecipe */ id?: number; + /** + * + * @type {string} + * @memberof ShoppingListRecipe + */ + name?: string; /** * * @type {number} @@ -1828,16 +2237,65 @@ export interface ShoppingListRecipe { recipe?: number | null; /** * - * @type {string} + * @type {number} * @memberof ShoppingListRecipe */ - recipe_name?: string; + mealplan?: number | null; /** * * @type {string} * @memberof ShoppingListRecipe */ servings: string; + /** + * + * @type {string} + * @memberof ShoppingListRecipe + */ + mealplan_note?: string; +} +/** + * + * @export + * @interface ShoppingListRecipeMealplan + */ +export interface ShoppingListRecipeMealplan { + /** + * + * @type {number} + * @memberof ShoppingListRecipeMealplan + */ + id?: number; + /** + * + * @type {string} + * @memberof ShoppingListRecipeMealplan + */ + name?: string; + /** + * + * @type {number} + * @memberof ShoppingListRecipeMealplan + */ + recipe?: number | null; + /** + * + * @type {number} + * @memberof ShoppingListRecipeMealplan + */ + mealplan?: number | null; + /** + * + * @type {string} + * @memberof ShoppingListRecipeMealplan + */ + servings: string; + /** + * + * @type {string} + * @memberof ShoppingListRecipeMealplan + */ + mealplan_note?: string; } /** * @@ -1851,6 +2309,12 @@ export interface ShoppingListRecipes { * @memberof ShoppingListRecipes */ id?: number; + /** + * + * @type {string} + * @memberof ShoppingListRecipes + */ + name?: string; /** * * @type {number} @@ -1859,35 +2323,22 @@ export interface ShoppingListRecipes { recipe?: number | null; /** * - * @type {string} + * @type {number} * @memberof ShoppingListRecipes */ - recipe_name?: string; + mealplan?: number | null; /** * * @type {string} * @memberof ShoppingListRecipes */ servings: string; -} -/** - * - * @export - * @interface ShoppingListShared - */ -export interface ShoppingListShared { - /** - * - * @type {number} - * @memberof ShoppingListShared - */ - id?: number; /** * * @type {string} - * @memberof ShoppingListShared + * @memberof ShoppingListRecipes */ - username?: string; + mealplan_note?: string; } /** * @@ -2008,10 +2459,10 @@ export interface Step { instruction?: string; /** * - * @type {Array} + * @type {Array} * @memberof Step */ - ingredients: Array; + ingredients: Array; /** * * @type {string} @@ -2044,10 +2495,10 @@ export interface Step { show_as_header?: boolean; /** * - * @type {StepFile} + * @type {RecipeFile} * @memberof Step */ - file?: StepFile | null; + file?: RecipeFile | null; /** * * @type {number} @@ -2079,141 +2530,6 @@ export enum StepTypeEnum { Recipe = 'RECIPE' } -/** - * - * @export - * @interface StepFile - */ -export interface StepFile { - /** - * - * @type {string} - * @memberof StepFile - */ - name: string; - /** - * - * @type {any} - * @memberof StepFile - */ - file?: any; - /** - * - * @type {number} - * @memberof StepFile - */ - id?: number; -} -/** - * - * @export - * @interface StepFood - */ -export interface StepFood { - /** - * - * @type {number} - * @memberof StepFood - */ - id?: number; - /** - * - * @type {string} - * @memberof StepFood - */ - name: string; - /** - * - * @type {string} - * @memberof StepFood - */ - description?: string; - /** - * - * @type {FoodRecipe} - * @memberof StepFood - */ - recipe?: FoodRecipe | null; - /** - * - * @type {boolean} - * @memberof StepFood - */ - ignore_shopping?: boolean; - /** - * - * @type {FoodSupermarketCategory} - * @memberof StepFood - */ - supermarket_category?: FoodSupermarketCategory | null; - /** - * - * @type {string} - * @memberof StepFood - */ - parent?: string; - /** - * - * @type {number} - * @memberof StepFood - */ - numchild?: number; -} -/** - * - * @export - * @interface StepIngredients - */ -export interface StepIngredients { - /** - * - * @type {number} - * @memberof StepIngredients - */ - id?: number; - /** - * - * @type {StepFood} - * @memberof StepIngredients - */ - food: StepFood | null; - /** - * - * @type {FoodSupermarketCategory} - * @memberof StepIngredients - */ - unit: FoodSupermarketCategory | null; - /** - * - * @type {string} - * @memberof StepIngredients - */ - amount: string; - /** - * - * @type {string} - * @memberof StepIngredients - */ - note?: string | null; - /** - * - * @type {number} - * @memberof StepIngredients - */ - order?: number; - /** - * - * @type {boolean} - * @memberof StepIngredients - */ - is_header?: boolean; - /** - * - * @type {boolean} - * @memberof StepIngredients - */ - no_amount?: boolean; -} /** * * @export @@ -2588,6 +2904,18 @@ export interface UserPreference { * @memberof UserPreference */ comments?: boolean; + /** + * + * @type {number} + * @memberof UserPreference + */ + shopping_auto_sync?: number; + /** + * + * @type {boolean} + * @memberof UserPreference + */ + mealplan_autoadd_shopping?: boolean; } /** @@ -4474,6 +4802,35 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listFoodInheritFields: async (options: any = {}): Promise => { + const localVarPath = `/api/food-inherit-field/`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -4778,24 +5135,23 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) /** * * @param {string} [query] Query string matched (fuzzy) against recipe name. In the future also fulltext search. - * @param {string} [keywords] Id of keyword a recipe should have. For multiple repeat parameter. - * @param {string} [foods] Id of food a recipe should have. For multiple repeat parameter. - * @param {number} [units] Id of unit a recipe should have. - * @param {number} [rating] Id of unit a recipe should have. - * @param {string} [books] Id of book a recipe should have. For multiple repeat parameter. - * @param {string} [steps] Id of a step a recipe should have. For multiple repeat parameter. - * @param {string} [keywordsOr] If recipe should have all (AND) or any (OR) of the provided keywords. - * @param {string} [foodsOr] If recipe should have all (AND) or any (OR) any of the provided foods. - * @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books. - * @param {string} [internal] true or false. If only internal recipes should be returned or not. - * @param {string} [random] true or false. returns the results in randomized order. - * @param {string} [_new] true or false. returns new results first in search results + * @param {number} [keywords] ID of keyword a recipe should have. For multiple repeat parameter. + * @param {number} [foods] ID of food a recipe should have. For multiple repeat parameter. + * @param {number} [units] ID of unit a recipe should have. + * @param {number} [rating] Rating a recipe should have. [0 - 5] + * @param {string} [books] ID of book a recipe should be in. For multiple repeat parameter. + * @param {string} [keywordsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided keywords. + * @param {string} [foodsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided foods. + * @param {string} [booksOr] If recipe should be in all (AND=false) or any (OR=<b>true</b>) of the provided books. + * @param {string} [internal] If only internal recipes should be returned. [true/<b>false</b>] + * @param {string} [random] Returns the results in randomized order. [true/<b>false</b>] + * @param {string} [_new] Returns new results first in search results. [true/<b>false</b>] * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRecipes: async (query?: string, keywords?: string, foods?: string, units?: number, rating?: number, books?: string, steps?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options: any = {}): Promise => { + listRecipes: async (query?: string, keywords?: number, foods?: number, units?: number, rating?: number, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options: any = {}): Promise => { const localVarPath = `/api/recipe/`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -4881,10 +5237,13 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) }, /** * + * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. + * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. + * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listShoppingListEntrys: async (options: any = {}): Promise => { + listShoppingListEntrys: async (id?: number, checked?: string, supermarket?: number, options: any = {}): Promise => { const localVarPath = `/api/shopping-list-entry/`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -4897,6 +5256,18 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; + if (id !== undefined) { + localVarQueryParameter['id'] = id; + } + + if (checked !== undefined) { + localVarQueryParameter['checked'] = checked; + } + + if (supermarket !== undefined) { + localVarQueryParameter['supermarket'] = supermarket; + } + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); @@ -4968,13 +5339,13 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) }, /** * - * @param {string} [query] Query string matched (fuzzy) against object name. + * @param {number} [recipe] ID of recipe a step is part of. For multiple repeat parameter. * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listSteps: async (query?: string, page?: number, pageSize?: number, options: any = {}): Promise => { + listSteps: async (recipe?: number, page?: number, pageSize?: number, options: any = {}): Promise => { const localVarPath = `/api/step/`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -4987,8 +5358,8 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; - if (query !== undefined) { - localVarQueryParameter['query'] = query; + if (recipe !== undefined) { + localVarQueryParameter['recipe'] = recipe; } if (page !== undefined) { @@ -6526,6 +6897,39 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + relatedRecipe: async (id: string, options: any = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('relatedRecipe', 'id', id) + const localVarPath = `/api/recipe/{id}/related/` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -6649,6 +7053,39 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id A unique integer value identifying this food inherit field. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + retrieveFoodInheritField: async (id: string, options: any = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('retrieveFoodInheritField', 'id', id) + const localVarPath = `/api/food-inherit-field/{id}/` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -7417,6 +7854,80 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * + * @param {string} id A unique integer value identifying this food. + * @param {FoodShoppingUpdate} [foodShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + shoppingFood: async (id: string, foodShoppingUpdate?: FoodShoppingUpdate, options: any = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('shoppingFood', 'id', id) + const localVarPath = `/api/food/{id}/shopping/` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(foodShoppingUpdate, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {RecipeShoppingUpdate} [recipeShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + shoppingRecipe: async (id: string, recipeShoppingUpdate?: RecipeShoppingUpdate, options: any = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('shoppingRecipe', 'id', id) + const localVarPath = `/api/recipe/{id}/shopping/` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(recipeShoppingUpdate, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -8913,10 +9424,19 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listCookLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listCookLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listCookLogs(page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listFoodInheritFields(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listFoodInheritFields(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} [query] Query string matched against food name. @@ -8927,7 +9447,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listFoods(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listFoods(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listFoods(query, root, tree, page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -8938,7 +9458,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listImportLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listImportLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listImportLogs(page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -8961,7 +9481,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listKeywords(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listKeywords(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listKeywords(query, root, tree, page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -9004,34 +9524,36 @@ export const ApiApiFp = function(configuration?: Configuration) { /** * * @param {string} [query] Query string matched (fuzzy) against recipe name. In the future also fulltext search. - * @param {string} [keywords] Id of keyword a recipe should have. For multiple repeat parameter. - * @param {string} [foods] Id of food a recipe should have. For multiple repeat parameter. - * @param {number} [units] Id of unit a recipe should have. - * @param {number} [rating] Id of unit a recipe should have. - * @param {string} [books] Id of book a recipe should have. For multiple repeat parameter. - * @param {string} [steps] Id of a step a recipe should have. For multiple repeat parameter. - * @param {string} [keywordsOr] If recipe should have all (AND) or any (OR) of the provided keywords. - * @param {string} [foodsOr] If recipe should have all (AND) or any (OR) any of the provided foods. - * @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books. - * @param {string} [internal] true or false. If only internal recipes should be returned or not. - * @param {string} [random] true or false. returns the results in randomized order. - * @param {string} [_new] true or false. returns new results first in search results + * @param {number} [keywords] ID of keyword a recipe should have. For multiple repeat parameter. + * @param {number} [foods] ID of food a recipe should have. For multiple repeat parameter. + * @param {number} [units] ID of unit a recipe should have. + * @param {number} [rating] Rating a recipe should have. [0 - 5] + * @param {string} [books] ID of book a recipe should be in. For multiple repeat parameter. + * @param {string} [keywordsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided keywords. + * @param {string} [foodsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided foods. + * @param {string} [booksOr] If recipe should be in all (AND=false) or any (OR=<b>true</b>) of the provided books. + * @param {string} [internal] If only internal recipes should be returned. [true/<b>false</b>] + * @param {string} [random] Returns the results in randomized order. [true/<b>false</b>] + * @param {string} [_new] Returns new results first in search results. [true/<b>false</b>] * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listRecipes(query?: string, keywords?: string, foods?: string, units?: number, rating?: number, books?: string, steps?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listRecipes(query, keywords, foods, units, rating, books, steps, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options); + async listRecipes(query?: string, keywords?: number, foods?: number, units?: number, rating?: number, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listRecipes(query, keywords, foods, units, rating, books, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** * + * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. + * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. + * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listShoppingListEntrys(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listShoppingListEntrys(options); + async listShoppingListEntrys(id?: number, checked?: string, supermarket?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listShoppingListEntrys(id, checked, supermarket, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -9054,14 +9576,14 @@ export const ApiApiFp = function(configuration?: Configuration) { }, /** * - * @param {string} [query] Query string matched (fuzzy) against object name. + * @param {number} [recipe] ID of recipe a step is part of. For multiple repeat parameter. * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listSteps(query?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listSteps(query, page, pageSize, options); + async listSteps(recipe?: number, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listSteps(recipe, page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -9080,7 +9602,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listSupermarketCategoryRelations(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listSupermarketCategoryRelations(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listSupermarketCategoryRelations(page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -9109,7 +9631,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listSyncLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listSyncLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listSyncLogs(page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -9130,7 +9652,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listUnits(query?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listUnits(query?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listUnits(query, page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -9168,7 +9690,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listViewLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listViewLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listViewLogs(page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -9510,6 +10032,16 @@ export const ApiApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.partialUpdateViewLog(id, viewLog, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async relatedRecipe(id: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.relatedRecipe(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -9550,6 +10082,16 @@ export const ApiApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveFood(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id A unique integer value identifying this food inherit field. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async retrieveFoodInheritField(id: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveFoodInheritField(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id A unique integer value identifying this import log. @@ -9780,6 +10322,28 @@ export const ApiApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveViewLog(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id A unique integer value identifying this food. + * @param {FoodShoppingUpdate} [foodShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async shoppingFood(id: string, foodShoppingUpdate?: FoodShoppingUpdate, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.shoppingFood(id, foodShoppingUpdate, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {RecipeShoppingUpdate} [recipeShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async shoppingRecipe(id: string, recipeShoppingUpdate?: RecipeShoppingUpdate, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.shoppingRecipe(id, recipeShoppingUpdate, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -10554,9 +11118,17 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listCookLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { + listCookLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listCookLogs(page, pageSize, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listFoodInheritFields(options?: any): AxiosPromise> { + return localVarFp.listFoodInheritFields(options).then((request) => request(axios, basePath)); + }, /** * * @param {string} [query] Query string matched against food name. @@ -10567,7 +11139,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listFoods(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): AxiosPromise { + listFoods(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listFoods(query, root, tree, page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10577,7 +11149,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listImportLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { + listImportLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listImportLogs(page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10598,7 +11170,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listKeywords(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): AxiosPromise { + listKeywords(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listKeywords(query, root, tree, page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10636,33 +11208,35 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: /** * * @param {string} [query] Query string matched (fuzzy) against recipe name. In the future also fulltext search. - * @param {string} [keywords] Id of keyword a recipe should have. For multiple repeat parameter. - * @param {string} [foods] Id of food a recipe should have. For multiple repeat parameter. - * @param {number} [units] Id of unit a recipe should have. - * @param {number} [rating] Id of unit a recipe should have. - * @param {string} [books] Id of book a recipe should have. For multiple repeat parameter. - * @param {string} [steps] Id of a step a recipe should have. For multiple repeat parameter. - * @param {string} [keywordsOr] If recipe should have all (AND) or any (OR) of the provided keywords. - * @param {string} [foodsOr] If recipe should have all (AND) or any (OR) any of the provided foods. - * @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books. - * @param {string} [internal] true or false. If only internal recipes should be returned or not. - * @param {string} [random] true or false. returns the results in randomized order. - * @param {string} [_new] true or false. returns new results first in search results + * @param {number} [keywords] ID of keyword a recipe should have. For multiple repeat parameter. + * @param {number} [foods] ID of food a recipe should have. For multiple repeat parameter. + * @param {number} [units] ID of unit a recipe should have. + * @param {number} [rating] Rating a recipe should have. [0 - 5] + * @param {string} [books] ID of book a recipe should be in. For multiple repeat parameter. + * @param {string} [keywordsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided keywords. + * @param {string} [foodsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided foods. + * @param {string} [booksOr] If recipe should be in all (AND=false) or any (OR=<b>true</b>) of the provided books. + * @param {string} [internal] If only internal recipes should be returned. [true/<b>false</b>] + * @param {string} [random] Returns the results in randomized order. [true/<b>false</b>] + * @param {string} [_new] Returns new results first in search results. [true/<b>false</b>] * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRecipes(query?: string, keywords?: string, foods?: string, units?: number, rating?: number, books?: string, steps?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any): AxiosPromise { - return localVarFp.listRecipes(query, keywords, foods, units, rating, books, steps, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options).then((request) => request(axios, basePath)); + listRecipes(query?: string, keywords?: number, foods?: number, units?: number, rating?: number, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any): AxiosPromise { + return localVarFp.listRecipes(query, keywords, foods, units, rating, books, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options).then((request) => request(axios, basePath)); }, /** * + * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. + * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. + * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listShoppingListEntrys(options?: any): AxiosPromise> { - return localVarFp.listShoppingListEntrys(options).then((request) => request(axios, basePath)); + listShoppingListEntrys(id?: number, checked?: string, supermarket?: number, options?: any): AxiosPromise> { + return localVarFp.listShoppingListEntrys(id, checked, supermarket, options).then((request) => request(axios, basePath)); }, /** * @@ -10682,14 +11256,14 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: }, /** * - * @param {string} [query] Query string matched (fuzzy) against object name. + * @param {number} [recipe] ID of recipe a step is part of. For multiple repeat parameter. * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listSteps(query?: string, page?: number, pageSize?: number, options?: any): AxiosPromise { - return localVarFp.listSteps(query, page, pageSize, options).then((request) => request(axios, basePath)); + listSteps(recipe?: number, page?: number, pageSize?: number, options?: any): AxiosPromise { + return localVarFp.listSteps(recipe, page, pageSize, options).then((request) => request(axios, basePath)); }, /** * @@ -10706,7 +11280,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listSupermarketCategoryRelations(page?: number, pageSize?: number, options?: any): AxiosPromise { + listSupermarketCategoryRelations(page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listSupermarketCategoryRelations(page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10732,7 +11306,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listSyncLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { + listSyncLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listSyncLogs(page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10751,7 +11325,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listUnits(query?: string, page?: number, pageSize?: number, options?: any): AxiosPromise { + listUnits(query?: string, page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listUnits(query, page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10785,7 +11359,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listViewLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { + listViewLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listViewLogs(page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -11096,6 +11670,15 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: partialUpdateViewLog(id: string, viewLog?: ViewLog, options?: any): AxiosPromise { return localVarFp.partialUpdateViewLog(id, viewLog, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + relatedRecipe(id: string, options?: any): AxiosPromise { + return localVarFp.relatedRecipe(id, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -11132,6 +11715,15 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: retrieveFood(id: string, options?: any): AxiosPromise { return localVarFp.retrieveFood(id, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {string} id A unique integer value identifying this food inherit field. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + retrieveFoodInheritField(id: string, options?: any): AxiosPromise { + return localVarFp.retrieveFoodInheritField(id, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} id A unique integer value identifying this import log. @@ -11339,6 +11931,26 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: retrieveViewLog(id: string, options?: any): AxiosPromise { return localVarFp.retrieveViewLog(id, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {string} id A unique integer value identifying this food. + * @param {FoodShoppingUpdate} [foodShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + shoppingFood(id: string, foodShoppingUpdate?: FoodShoppingUpdate, options?: any): AxiosPromise { + return localVarFp.shoppingFood(id, foodShoppingUpdate, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {RecipeShoppingUpdate} [recipeShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + shoppingRecipe(id: string, recipeShoppingUpdate?: RecipeShoppingUpdate, options?: any): AxiosPromise { + return localVarFp.shoppingRecipe(id, recipeShoppingUpdate, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -12199,6 +12811,16 @@ export class ApiApi extends BaseAPI { return ApiApiFp(this.configuration).listCookLogs(page, pageSize, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public listFoodInheritFields(options?: any) { + return ApiApiFp(this.configuration).listFoodInheritFields(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} [query] Query string matched against food name. @@ -12294,36 +12916,38 @@ export class ApiApi extends BaseAPI { /** * * @param {string} [query] Query string matched (fuzzy) against recipe name. In the future also fulltext search. - * @param {string} [keywords] Id of keyword a recipe should have. For multiple repeat parameter. - * @param {string} [foods] Id of food a recipe should have. For multiple repeat parameter. - * @param {number} [units] Id of unit a recipe should have. - * @param {number} [rating] Id of unit a recipe should have. - * @param {string} [books] Id of book a recipe should have. For multiple repeat parameter. - * @param {string} [steps] Id of a step a recipe should have. For multiple repeat parameter. - * @param {string} [keywordsOr] If recipe should have all (AND) or any (OR) of the provided keywords. - * @param {string} [foodsOr] If recipe should have all (AND) or any (OR) any of the provided foods. - * @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books. - * @param {string} [internal] true or false. If only internal recipes should be returned or not. - * @param {string} [random] true or false. returns the results in randomized order. - * @param {string} [_new] true or false. returns new results first in search results + * @param {number} [keywords] ID of keyword a recipe should have. For multiple repeat parameter. + * @param {number} [foods] ID of food a recipe should have. For multiple repeat parameter. + * @param {number} [units] ID of unit a recipe should have. + * @param {number} [rating] Rating a recipe should have. [0 - 5] + * @param {string} [books] ID of book a recipe should be in. For multiple repeat parameter. + * @param {string} [keywordsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided keywords. + * @param {string} [foodsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided foods. + * @param {string} [booksOr] If recipe should be in all (AND=false) or any (OR=<b>true</b>) of the provided books. + * @param {string} [internal] If only internal recipes should be returned. [true/<b>false</b>] + * @param {string} [random] Returns the results in randomized order. [true/<b>false</b>] + * @param {string} [_new] Returns new results first in search results. [true/<b>false</b>] * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof ApiApi */ - public listRecipes(query?: string, keywords?: string, foods?: string, units?: number, rating?: number, books?: string, steps?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any) { - return ApiApiFp(this.configuration).listRecipes(query, keywords, foods, units, rating, books, steps, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options).then((request) => request(this.axios, this.basePath)); + public listRecipes(query?: string, keywords?: number, foods?: number, units?: number, rating?: number, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any) { + return ApiApiFp(this.configuration).listRecipes(query, keywords, foods, units, rating, books, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options).then((request) => request(this.axios, this.basePath)); } /** * + * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. + * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. + * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof ApiApi */ - public listShoppingListEntrys(options?: any) { - return ApiApiFp(this.configuration).listShoppingListEntrys(options).then((request) => request(this.axios, this.basePath)); + public listShoppingListEntrys(id?: number, checked?: string, supermarket?: number, options?: any) { + return ApiApiFp(this.configuration).listShoppingListEntrys(id, checked, supermarket, options).then((request) => request(this.axios, this.basePath)); } /** @@ -12348,15 +12972,15 @@ export class ApiApi extends BaseAPI { /** * - * @param {string} [query] Query string matched (fuzzy) against object name. + * @param {number} [recipe] ID of recipe a step is part of. For multiple repeat parameter. * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof ApiApi */ - public listSteps(query?: string, page?: number, pageSize?: number, options?: any) { - return ApiApiFp(this.configuration).listSteps(query, page, pageSize, options).then((request) => request(this.axios, this.basePath)); + public listSteps(recipe?: number, page?: number, pageSize?: number, options?: any) { + return ApiApiFp(this.configuration).listSteps(recipe, page, pageSize, options).then((request) => request(this.axios, this.basePath)); } /** @@ -12846,6 +13470,17 @@ export class ApiApi extends BaseAPI { return ApiApiFp(this.configuration).partialUpdateViewLog(id, viewLog, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public relatedRecipe(id: string, options?: any) { + return ApiApiFp(this.configuration).relatedRecipe(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} id A unique integer value identifying this automation. @@ -12890,6 +13525,17 @@ export class ApiApi extends BaseAPI { return ApiApiFp(this.configuration).retrieveFood(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {string} id A unique integer value identifying this food inherit field. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public retrieveFoodInheritField(id: string, options?: any) { + return ApiApiFp(this.configuration).retrieveFoodInheritField(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} id A unique integer value identifying this import log. @@ -13143,6 +13789,30 @@ export class ApiApi extends BaseAPI { return ApiApiFp(this.configuration).retrieveViewLog(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {string} id A unique integer value identifying this food. + * @param {FoodShoppingUpdate} [foodShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public shoppingFood(id: string, foodShoppingUpdate?: FoodShoppingUpdate, options?: any) { + return ApiApiFp(this.configuration).shoppingFood(id, foodShoppingUpdate, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {RecipeShoppingUpdate} [recipeShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public shoppingRecipe(id: string, recipeShoppingUpdate?: RecipeShoppingUpdate, options?: any) { + return ApiApiFp(this.configuration).shoppingRecipe(id, recipeShoppingUpdate, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} id A unique integer value identifying this automation. diff --git a/vue/src/utils/utils.js b/vue/src/utils/utils.js index 2be64a465..e4bce0a6e 100644 --- a/vue/src/utils/utils.js +++ b/vue/src/utils/utils.js @@ -16,6 +16,7 @@ import Vue from "vue" import { Actions, Models } from "./models" export const ToastMixin = { + name: "ToastMixin", methods: { makeToast: function (title, message, variant = null) { return makeToast(title, message, variant) @@ -147,12 +148,17 @@ export function resolveDjangoUrl(url, params = null) { /* * other utilities * */ - -export function getUserPreference(pref) { - if (window.USER_PREF === undefined) { +export function getUserPreference(pref = undefined) { + let user_preference + if (document.getElementById("user_preference")) { + user_preference = JSON.parse(document.getElementById("user_preference").textContent) + } else { return undefined } - return window.USER_PREF[pref] + if (pref) { + return user_preference[pref] + } + return user_preference } export function calculateAmount(amount, factor) { @@ -214,6 +220,11 @@ export const ApiMixin = { return { Models: Models, Actions: Actions, + FoodCreateDefault: function (form) { + form.inherit_ignore = getUserPreference("food_ignore_default") + form.inherit = form.supermarket_category.length > 0 + return form + }, } }, methods: { @@ -525,3 +536,11 @@ const specialCases = { }) }, } + +export const formFunctions = { + FoodCreateDefault: function (form) { + form.fields.filter((x) => x.field === "ignore_inherit")[0].value = getUserPreference("food_ignore_default") + form.fields.filter((x) => x.field === "inherit")[0].value = getUserPreference("food_ignore_default").length > 0 + return form + }, +} diff --git a/vue/vue.config.js b/vue/vue.config.js index cfbf9872b..90cdf7293 100644 --- a/vue/vue.config.js +++ b/vue/vue.config.js @@ -37,8 +37,8 @@ const pages = { entry: "./src/apps/MealPlanView/main.js", chunks: ["chunk-vendors"], }, - checklist_view: { - entry: "./src/apps/ChecklistView/main.js", + shopping_list_view: { + entry: "./src/apps/ShoppingListView/main.js", chunks: ["chunk-vendors"], }, } @@ -47,7 +47,7 @@ module.exports = { pages: pages, filenameHashing: false, productionSourceMap: false, - publicPath: process.env.NODE_ENV === "production" ? "" : "http://localhost:8080/", + publicPath: process.env.NODE_ENV === "production" ? "/static/vue" : "http://localhost:8080/", outputDir: "../cookbook/static/vue/", runtimeCompiler: true, pwa: { @@ -90,18 +90,9 @@ module.exports = { }, }, // TODO make this conditional on .env DEBUG = FALSE - config.optimization.minimize(true) + config.optimization.minimize(false) ) - //TODO somehow remov them as they are also added to the manifest config of the service worker - /* - Object.keys(pages).forEach(page => { - config.plugins.delete(`html-${page}`); - config.plugins.delete(`preload-${page}`); - config.plugins.delete(`prefetch-${page}`); - }) - */ - config.plugin("BundleTracker").use(BundleTracker, [{ relativePath: true, path: "../vue/" }]) config.resolve.alias.set("__STATIC__", "static") From 0b1d8bbd5fea5944f190bcbc98fc0f99126a44b6 Mon Sep 17 00:00:00 2001 From: smilerz Date: Fri, 1 Oct 2021 14:20:41 -0500 Subject: [PATCH 13/91] WIP --- cookbook/models.py | 4 ++-- cookbook/serializer.py | 2 +- cookbook/tests/conftest.py | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cookbook/models.py b/cookbook/models.py index d4d7d6985..e33854350 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -488,7 +488,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): 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? + 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) @@ -827,7 +827,7 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod def get_owner(self): try: - return self.entries.first().created_by or self.shoppinglist_set.first().created_by + return getattr(self.entries.first(), 'created_by', None) or getattr(self.shoppinglist_set.first(), 'created_by', None) except AttributeError: return None diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 24c333f46..ce21111b9 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -365,7 +365,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) recipe = RecipeSimpleSerializer(allow_null=True, required=False) shopping = serializers.SerializerMethodField('get_shopping_status') - ignore_inherit = FoodInheritFieldSerializer(many=True) + ignore_inherit = FoodInheritFieldSerializer(allow_null=True, many=True, required=False) recipe_filter = 'steps__ingredients__food' diff --git a/cookbook/tests/conftest.py b/cookbook/tests/conftest.py index 48c4e9c6c..23f71e1a1 100644 --- a/cookbook/tests/conftest.py +++ b/cookbook/tests/conftest.py @@ -5,10 +5,10 @@ import uuid import pytest from django.contrib import auth -from django.contrib.auth.models import User, Group +from django.contrib.auth.models import Group, User from django_scopes import scopes_disabled -from cookbook.models import Space, Recipe, Step, Ingredient, Food, Unit +from cookbook.models import Food, Ingredient, Recipe, Space, Step, Unit # hack from https://github.com/raphaelm/django-scopes to disable scopes for all fixtures @@ -52,8 +52,8 @@ def get_random_recipe(space_1, u1_s1): internal=True, ) - s1 = Step.objects.create(name=uuid.uuid4(), instruction=uuid.uuid4(), space=space_1, ) - s2 = Step.objects.create(name=uuid.uuid4(), instruction=uuid.uuid4(), space=space_1, ) + s1 = Step.objects.create(name=str(uuid.uuid4()), instruction=str(uuid.uuid4()), space=space_1, ) + s2 = Step.objects.create(name=str(uuid.uuid4()), instruction=str(uuid.uuid4()), space=space_1, ) r.steps.add(s1) r.steps.add(s2) @@ -63,8 +63,8 @@ def get_random_recipe(space_1, u1_s1): Ingredient.objects.create( amount=1, food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0], - unit=Unit.objects.create(name=uuid.uuid4(), space=space_1, ), - note=uuid.uuid4(), + unit=Unit.objects.create(name=str(uuid.uuid4()), space=space_1, ), + note=str(uuid.uuid4()), space=space_1, ) ) @@ -73,8 +73,8 @@ def get_random_recipe(space_1, u1_s1): Ingredient.objects.create( amount=1, food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0], - unit=Unit.objects.create(name=uuid.uuid4(), space=space_1, ), - note=uuid.uuid4(), + unit=Unit.objects.create(name=str(uuid.uuid4()), space=space_1, ), + note=str(uuid.uuid4()), space=space_1, ) ) From e243e089cce4b3bf2e745886a28a37eadf483938 Mon Sep 17 00:00:00 2001 From: smilerz Date: Fri, 1 Oct 2021 15:59:21 -0500 Subject: [PATCH 14/91] WIP --- cookbook/forms.py | 9 +-- cookbook/helper/shopping_helper.py | 41 ++----------- .../migrations/0158_auto_20211001_1552.py | 58 +++++++++++++++++++ cookbook/models.py | 4 +- cookbook/serializer.py | 5 +- .../tests/api/test_api_shopping_list_entry.py | 12 +--- 6 files changed, 73 insertions(+), 56 deletions(-) create mode 100644 cookbook/migrations/0158_auto_20211001_1552.py diff --git a/cookbook/forms.py b/cookbook/forms.py index 1801efd95..9dae6d9b4 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -45,7 +45,8 @@ 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', 'comments', + 'plan_share', 'shopping_share', 'ingredient_decimals', 'shopping_auto_sync', + 'comments' ) labels = { @@ -74,7 +75,8 @@ class UserPreferenceForm(forms.ModelForm): 'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501 'plan_share': _( 'Users with whom newly created meal plans should be shared by default.'), - 'shopping_share': _('Users with whom to share shopping lists.'), + 'shopping_share': _( + 'Users with whom to share shopping lists.'), # noqa: E501 'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501 @@ -91,8 +93,7 @@ class UserPreferenceForm(forms.ModelForm): widgets = { 'plan_share': MultiSelectWidget, - 'shopping_share': MultiSelectWidget, - + 'shopping_share': MultiSelectWidget } diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index 26d590f85..731b8f35c 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -1,40 +1,11 @@ -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.db.models import Q 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 +from cookbook.models import UserPreference 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') + 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 diff --git a/cookbook/migrations/0158_auto_20211001_1552.py b/cookbook/migrations/0158_auto_20211001_1552.py new file mode 100644 index 000000000..7916731ef --- /dev/null +++ b/cookbook/migrations/0158_auto_20211001_1552.py @@ -0,0 +1,58 @@ +# Generated by Django 3.2.7 on 2021-10-01 20:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +def copy_values_to_sle(apps, schema_editor): + ShoppingListEntry = apps.get_model('app', 'ShoppingListEntry') + db_alias = schema_editor.connection.alias + from django.db.models import F + MetaDataValue.objects.using(db_alias).all().update( + short_value=F('shoppinglist__created_by') + ) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cookbook', '0157_alter_searchpreference_trigram'), + ] + + 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='shoppinglistentry', + name='recipe', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.recipe'), + ), + migrations.AddField( + model_name='userpreference', + name='shopping_share', + field=models.ManyToManyField(blank=True, related_name='shopping_share', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index e33854350..6d0342bf0 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -487,8 +487,6 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): 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) @@ -840,10 +838,10 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model amount = models.DecimalField(default=0, decimal_places=16, max_digits=32) order = models.IntegerField(default=0) checked = models.BooleanField(default=False) + recipe = models.ForeignKey(Recipe, on_delete=models.SET_NULL, null=True, blank=True) 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') diff --git a/cookbook/serializer.py b/cookbook/serializer.py index ce21111b9..3d714062a 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -390,10 +390,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR class Meta: model = Food - fields = ( - 'id', 'name', 'description', 'shopping', 'recipe', 'ignore_shopping', 'supermarket_category', - 'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'inherit', 'ignore_inherit', - ) + fields = ('id', 'name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category', 'image', 'parent', 'numchild', 'numrecipe', 'on_hand') read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') diff --git a/cookbook/tests/api/test_api_shopping_list_entry.py b/cookbook/tests/api/test_api_shopping_list_entry.py index 846f67d86..0cf36597c 100644 --- a/cookbook/tests/api/test_api_shopping_list_entry.py +++ b/cookbook/tests/api/test_api_shopping_list_entry.py @@ -119,13 +119,5 @@ def test_delete(u1_s1, u1_s2, obj_1): assert r.status_code == 204 -# TODO test sharing -# TODO test completed entries still visible if today, but not yesterday -# TODO test create shopping list from recipe -# TODO test delete shopping list from recipe - include created by, shared with and not shared with -# TODO test create shopping list from food -# TODO test delete shopping list from food - include created by, shared with and not shared with -# TODO test create shopping list from mealplan -# TODO test create shopping list from recipe, excluding ingredients -# TODO test auto creating shopping list from meal plan -# TODO test excluding on-hand when auto creating shopping list +# test sharing +# test completed entries still visible if today, but not yesterday \ No newline at end of file From f5f9380344fd4dc3e1a8f87bda49f2dfde3d25fc Mon Sep 17 00:00:00 2001 From: smilerz Date: Sat, 2 Oct 2021 12:47:59 -0500 Subject: [PATCH 15/91] model migrations --- .../migrations/0158_auto_20211001_1552.py | 58 ------------------- .../0159_add_shoppinglistentry_fields.py | 11 +++- .../0160_delete_shoppinglist_orphans.py | 28 +-------- cookbook/models.py | 4 +- cookbook/serializer.py | 41 +++++++------ 5 files changed, 34 insertions(+), 108 deletions(-) delete mode 100644 cookbook/migrations/0158_auto_20211001_1552.py diff --git a/cookbook/migrations/0158_auto_20211001_1552.py b/cookbook/migrations/0158_auto_20211001_1552.py deleted file mode 100644 index 7916731ef..000000000 --- a/cookbook/migrations/0158_auto_20211001_1552.py +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by Django 3.2.7 on 2021-10-01 20:52 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -def copy_values_to_sle(apps, schema_editor): - ShoppingListEntry = apps.get_model('app', 'ShoppingListEntry') - db_alias = schema_editor.connection.alias - from django.db.models import F - MetaDataValue.objects.using(db_alias).all().update( - short_value=F('shoppinglist__created_by') - ) - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('cookbook', '0157_alter_searchpreference_trigram'), - ] - - 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='shoppinglistentry', - name='recipe', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.recipe'), - ), - migrations.AddField( - model_name='userpreference', - name='shopping_share', - field=models.ManyToManyField(blank=True, related_name='shopping_share', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/cookbook/migrations/0159_add_shoppinglistentry_fields.py b/cookbook/migrations/0159_add_shoppinglistentry_fields.py index 9df880d08..a002095b1 100644 --- a/cookbook/migrations/0159_add_shoppinglistentry_fields.py +++ b/cookbook/migrations/0159_add_shoppinglistentry_fields.py @@ -6,7 +6,7 @@ from django.conf import settings from django.db import migrations, models from django_scopes import scopes_disabled -from cookbook.models import PermissionModelMixin, ShoppingListEntry +from cookbook.models import ShoppingListEntry def copy_values_to_sle(apps, schema_editor): @@ -16,8 +16,10 @@ def copy_values_to_sle(apps, schema_editor): if entry.shoppinglist_set.first(): entry.created_by = entry.shoppinglist_set.first().created_by entry.space = entry.shoppinglist_set.first().space + if entry.list_recipe: + entry.recipe = entry.list_recipe.recipe if entries: - ShoppingListEntry.objects.bulk_update(entries, ["created_by", "space", ]) + ShoppingListEntry.objects.bulk_update(entries, ["created_by", "recipe"]) class Migration(migrations.Migration): @@ -50,6 +52,11 @@ class Migration(migrations.Migration): field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='auth.user'), preserve_default=False, ), + migrations.AddField( + model_name='shoppinglistentry', + name='recipe', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.recipe'), + ), migrations.AddField( model_name='userpreference', name='shopping_share', diff --git a/cookbook/migrations/0160_delete_shoppinglist_orphans.py b/cookbook/migrations/0160_delete_shoppinglist_orphans.py index 27fb0edb9..38139f606 100644 --- a/cookbook/migrations/0160_delete_shoppinglist_orphans.py +++ b/cookbook/migrations/0160_delete_shoppinglist_orphans.py @@ -1,16 +1,13 @@ # 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 +import django.db.models.deletion from django.utils.timezone import utc -from django_scopes import scopes_disabled -from cookbook.models import FoodInheritField, ShoppingListEntry +from django_scopes import scopes_disabled +from cookbook.models import ShoppingListEntry def delete_orphaned_sle(apps, schema_editor): @@ -19,23 +16,6 @@ def delete_orphaned_sle(apps, schema_editor): 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 = [ @@ -45,6 +25,4 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(delete_orphaned_sle), - migrations.RunPython(create_inheritfields), - migrations.RunPython(set_completed_at), ] diff --git a/cookbook/models.py b/cookbook/models.py index 6d0342bf0..d6bc89cff 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -831,7 +831,7 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin): - list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries') + list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True) # TODO deprecate food = models.ForeignKey(Food, on_delete=models.CASCADE) 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) @@ -844,7 +844,7 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model completed_at = models.DateTimeField(null=True, blank=True) space = models.ForeignKey(Space, on_delete=models.CASCADE) - objects = ScopedManager(space='space') + objects = ScopedManager(space='shoppinglist__space') @classmethod @atomic diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 3d714062a..a0d0ce334 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -372,6 +372,15 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR def get_shopping_status(self, obj): return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0 + def get_fields(self, *args, **kwargs): + fields = super().get_fields(*args, **kwargs) + print('food', self.__class__, self.parent.__class__) + # extended values are computationally expensive and not needed in normal circumstances + if not bool(int(self.context['request'].query_params.get('extended', False))) or not self.parent: + del fields['image'] + del fields['numrecipe'] + return fields + def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() validated_data['space'] = self.context['request'].space @@ -670,41 +679,31 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): def get_fields(self, *args, **kwargs): fields = super().get_fields(*args, **kwargs) - + print('shoppinglist', self.__class__, self.parent.__class__) # 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] + # extended values are computationally expensive and not needed in normal circumstances + elif not bool(int(self.context['request'].query_params.get('extended', False))) or not self.parent: + del fields['notes'] 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) + def update(self, instance, validated_data): + if validated_data['checked']: + validated_data['completed_at'] = timezone.now() + return super().update(instance, validated_data) + class Meta: model = ShoppingListEntry fields = ( - 'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan', - 'created_by', 'created_at', 'completed_at', 'delay_until' + 'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked', + 'created_by', 'created_at', 'notes', 'completed_at' ) read_only_fields = ('id', 'created_by', 'created_at',) From 6eea7ac99becfb340cbaa6ff29f01e3d57fb5274 Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 7 Oct 2021 12:35:46 -0500 Subject: [PATCH 16/91] model changes and GenericAutoSchema --- cookbook/models.py | 57 ++- cookbook/schemas.py | 5 +- cookbook/serializer.py | 95 ++-- .../tests/api/test_api_shopping_list_entry.py | 5 +- vue/src/utils/models.js | 11 +- vue/src/utils/openapi/api.ts | 418 ++++++++++++------ 6 files changed, 391 insertions(+), 200 deletions(-) diff --git a/cookbook/models.py b/cookbook/models.py index d6bc89cff..3c6764751 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -781,17 +781,16 @@ 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) + # TODO override create method to check if recipes are always added + # @classmethod + # def generate_shoppinglist(self, ingredients=None): + # recipe_list = ShoppingListRecipe.objects.create() + # if not ingredients: + # ingredients = Ingredient.objects.filter(step__recipe=self.recipe) + # for i in ingredients: + # ShoppingListEntry.objects.create( + + # ) def get_label(self): if self.title: @@ -831,20 +830,50 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin): - list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True) # TODO deprecate + list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True) # TODO remove when shoppinglist is deprecated food = models.ForeignKey(Food, on_delete=models.CASCADE) 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) - recipe = models.ForeignKey(Recipe, on_delete=models.SET_NULL, null=True, blank=True) 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) space = models.ForeignKey(Space, on_delete=models.CASCADE) - objects = ScopedManager(space='shoppinglist__space') + objects = ScopedManager(space='space') + + @classmethod + def list_from_recipe(self, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None): + try: + r = recipe or mealplan.recipe + except AttributeError: + raise ValueError(_("You must supply a recipe or mealplan")) + + created_by = created_by or getattr(mealplan, '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: + 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) + shoppinglist = [ + ShoppingListEntry( + list_recipe=list_recipe, + food=i.food, + unit=i.unit, + ingredient=i, + amount=i.amount * Decimal(servings), + created_by=created_by, + space=space + ) + for i in ingredients + ] + return ShoppingListEntry.objects.bulk_create(shoppinglist) @classmethod @atomic diff --git a/cookbook/schemas.py b/cookbook/schemas.py index 36ce66555..9230268b0 100644 --- a/cookbook/schemas.py +++ b/cookbook/schemas.py @@ -3,11 +3,10 @@ from rest_framework.schemas.utils import is_list_view class QueryParam(object): - def __init__(self, name, description=None, qtype='string', required=False): + def __init__(self, name, description=None, qtype='string'): self.name = name self.description = description self.qtype = qtype - self.required = required def __str__(self): return f'{self.name}, {self.qtype}, {self.description}' @@ -20,7 +19,7 @@ class QueryParamAutoSchema(AutoSchema): parameters = super().get_path_parameters(path, method) for q in self.view.query_params: parameters.append({ - "name": q.name, "in": "query", "required": q.required, + "name": q.name, "in": "query", "required": False, "description": q.description, 'schema': {'type': q.qtype, }, }) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index a0d0ce334..3b9c3d519 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -35,20 +35,13 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): except KeyError: api_serializer = None # extended values are computationally expensive and not needed in normal circumstances - try: - if bool(int( - self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer: - return fields - except AttributeError: - pass - except KeyError: - pass - try: + # another choice is to only return the fields when self.__class__ = serializer and not worry about 'extended' + if self.context['request'] and bool(int(self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer: + return fields + else: del fields['image'] del fields['numrecipe'] - except KeyError: - pass - return fields + return fields def get_image(self, obj): # TODO add caching @@ -634,37 +627,18 @@ 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') + mealplan_note = serializers.SerializerMethodField('get_note_markdown') 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) + def get_note_markdown(self, obj): + return obj.mealplan and markdown(obj.mealplan.note) class Meta: model = ShoppingListRecipe - fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note') + fields = ('id', 'recipe', 'mealplan', 'recipe_name', 'servings', 'mealplan_note') read_only_fields = ('id',) @@ -674,36 +648,63 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): 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) + created_by = UserNameSerializer() def get_fields(self, *args, **kwargs): fields = super().get_fields(*args, **kwargs) - print('shoppinglist', self.__class__, self.parent.__class__) + # try: + # # this serializer is the parent serializer for the API + # api_serializer = self.context['view'].serializer_class + # except Exception: + # # this serializer is probably nested or a foreign key + # api_serializer = None + # 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] # extended values are computationally expensive and not needed in normal circumstances - elif not bool(int(self.context['request'].query_params.get('extended', False))) or not self.parent: - del fields['notes'] + # elif bool(int(self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer: + # pass + # else: + # del fields['recipe_mealplan'] return fields + def run_validation(self, data): + if ( + data.get('checked', False) + and not (id := data.get('id', None)) + and not ShoppingListEntry.objects.get(id=id).checked + ): + # if checked flips from false to true set completed datetime + data['completed_at'] = timezone.now() + + ############################################################ + # temporary while old and new shopping lists are both in use + try: + # this serializer is the parent serializer for the API + api_serializer = self.context['view'].serializer_class + except Exception: + # this serializer is probably nested or a foreign key + api_serializer = None + if self.context['request'].method == 'POST' and not self.__class__ == api_serializer: + data['space'] = self.context['request'].space.id + data['created_by'] = self.context['request'].user.id + ############################################################ + if self.context['request'].method == 'POST' and self.__class__ == api_serializer: + data['created_by'] = {'id': self.context['request'].user.id} + 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) - def update(self, instance, validated_data): - if validated_data['checked']: - validated_data['completed_at'] = timezone.now() - return super().update(instance, validated_data) - class Meta: model = ShoppingListEntry fields = ( - 'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked', - 'created_by', 'created_at', 'notes', 'completed_at' + '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',) diff --git a/cookbook/tests/api/test_api_shopping_list_entry.py b/cookbook/tests/api/test_api_shopping_list_entry.py index 0cf36597c..877f42350 100644 --- a/cookbook/tests/api/test_api_shopping_list_entry.py +++ b/cookbook/tests/api/test_api_shopping_list_entry.py @@ -120,4 +120,7 @@ def test_delete(u1_s1, u1_s2, obj_1): # test sharing -# test completed entries still visible if today, but not yesterday \ No newline at end of file +# test completed entries still visible if today, but not yesterday +# test create shopping list from recipe +# test create shopping list from mealplan +# test create shopping list from recipe, excluding ingredients \ No newline at end of file diff --git a/vue/src/utils/models.js b/vue/src/utils/models.js index 312e6cc91..7b075d4e4 100644 --- a/vue/src/utils/models.js +++ b/vue/src/utils/models.js @@ -404,19 +404,16 @@ export class Models { } static RECIPE = { - name: i18n.t("Recipe"), - apiName: "Recipe", - list: { - params: ["query", "keywords", "foods", "units", "rating", "books", "keywordsOr", "foodsOr", "booksOr", "internal", "random", "_new", "page", "pageSize", "options"], + 'name': i18n.t('Recipe'), + 'apiName': 'Recipe', + 'list': { + 'params': ['query', 'keywords', 'foods', 'units', 'rating', 'books', 'keywordsOr', 'foodsOr', 'booksOr', 'internal', 'random', '_new', 'page', 'pageSize', 'options'], // 'config': { // 'foods': {'type': 'string'}, // 'keywords': {'type': 'string'}, // 'books': {'type': 'string'}, // } }, - shopping: { - params: ["id", ["id", "list_recipe", "ingredients", "servings"]], - }, } static USER_NAME = { diff --git a/vue/src/utils/openapi/api.ts b/vue/src/utils/openapi/api.ts index 3c20e90c4..3687b7f6f 100644 --- a/vue/src/utils/openapi/api.ts +++ b/vue/src/utils/openapi/api.ts @@ -227,12 +227,6 @@ export interface Food { * @memberof Food */ parent?: string; - /** - * - * @type {number} - * @memberof Food - */ - numchild?: number; /** * * @type {boolean} @@ -244,63 +238,7 @@ export interface Food { * @type {boolean} * @memberof Food */ - inherit?: boolean; - /** - * - * @type {Array} - * @memberof Food - */ - ignore_inherit?: Array | null; -} -/** - * - * @export - * @interface FoodIgnoreInherit - */ -export interface FoodIgnoreInherit { - /** - * - * @type {number} - * @memberof FoodIgnoreInherit - */ - id?: number; - /** - * - * @type {string} - * @memberof FoodIgnoreInherit - */ - name?: string; - /** - * - * @type {string} - * @memberof FoodIgnoreInherit - */ - field?: string; -} -/** - * - * @export - * @interface FoodInheritField - */ -export interface FoodInheritField { - /** - * - * @type {number} - * @memberof FoodInheritField - */ - id?: number; - /** - * - * @type {string} - * @memberof FoodInheritField - */ - name?: string; - /** - * - * @type {string} - * @memberof FoodInheritField - */ - field?: string; + on_hand?: boolean; } /** * @@ -501,6 +439,18 @@ export interface ImportLogKeyword { * @memberof ImportLogKeyword */ numchild?: number; + /** + * + * @type {string} + * @memberof ImportLogKeyword + */ + created_at?: string; + /** + * + * @type {string} + * @memberof ImportLogKeyword + */ + updated_at?: string; } /** * @@ -963,13 +913,7 @@ export interface Keyword { * @type {string} * @memberof Keyword */ - name: string; - /** - * - * @type {string} - * @memberof Keyword - */ - icon?: string | null; + parent?: string; /** * * @type {string} @@ -981,13 +925,7 @@ export interface Keyword { * @type {string} * @memberof Keyword */ - description?: string; - /** - * - * @type {string} - * @memberof Keyword - */ - parent?: string; + created_at?: string; /** * * @type {number} @@ -1441,10 +1379,10 @@ export interface RecipeBook { icon?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof RecipeBook */ - shared: Array; + shared: Array; /** * * @type {string} @@ -1618,13 +1556,7 @@ export interface RecipeKeywords { * @type {string} * @memberof RecipeKeywords */ - name: string; - /** - * - * @type {string} - * @memberof RecipeKeywords - */ - icon?: string | null; + parent?: string; /** * * @type {string} @@ -1636,13 +1568,7 @@ export interface RecipeKeywords { * @type {string} * @memberof RecipeKeywords */ - description?: string; - /** - * - * @type {string} - * @memberof RecipeKeywords - */ - parent?: string; + created_at?: string; /** * * @type {number} @@ -1993,10 +1919,10 @@ export interface ShoppingList { entries: Array | null; /** * - * @type {Array} + * @type {Array} * @memberof ShoppingList */ - shared: Array; + shared: Array; /** * * @type {boolean} @@ -2077,6 +2003,18 @@ export interface ShoppingListEntries { * @memberof ShoppingListEntries */ ingredient?: number | null; + /** + * + * @type {FoodSupermarketCategory} + * @memberof ShoppingListEntries + */ + unit?: FoodSupermarketCategory | null; + /** + * + * @type {number} + * @memberof ShoppingListEntries + */ + ingredient?: number | null; /** * * @type {string} @@ -2112,7 +2050,7 @@ export interface ShoppingListEntries { * @type {ShoppingListCreatedBy} * @memberof ShoppingListEntries */ - created_by?: ShoppingListCreatedBy; + created_by: ShoppingListCreatedBy; /** * * @type {string} @@ -2124,7 +2062,7 @@ export interface ShoppingListEntries { * @type {string} * @memberof ShoppingListEntries */ - completed_at?: string; + completed_at?: string | null; } /** * @@ -2231,10 +2169,22 @@ export interface ShoppingListRecipe { name?: string; /** * - * @type {number} - * @memberof ShoppingListRecipe + * @type {FoodSupermarketCategory} + * @memberof ShoppingListEntry */ - recipe?: number | null; + unit?: FoodSupermarketCategory | null; + /** + * + * @type {number} + * @memberof ShoppingListEntry + */ + ingredient?: number | null; + /** + * + * @type {string} + * @memberof ShoppingListEntry + */ + ingredient_note?: string; /** * * @type {number} @@ -2252,7 +2202,31 @@ export interface ShoppingListRecipe { * @type {string} * @memberof ShoppingListRecipe */ - mealplan_note?: string; + checked?: boolean; + /** + * + * @type {ShoppingListRecipeMealplan} + * @memberof ShoppingListEntry + */ + recipe_mealplan?: ShoppingListRecipeMealplan; + /** + * + * @type {ShoppingListCreatedBy} + * @memberof ShoppingListEntry + */ + created_by: ShoppingListCreatedBy; + /** + * + * @type {string} + * @memberof ShoppingListEntry + */ + created_at?: string; + /** + * + * @type {string} + * @memberof ShoppingListEntry + */ + completed_at?: string | null; } /** * @@ -2281,6 +2255,61 @@ export interface ShoppingListRecipeMealplan { /** * * @type {number} + * @memberof ShoppingListRecipe + */ + mealplan?: number | null; + /** + * + * @type {string} + * @memberof ShoppingListRecipe + */ + mealplan?: number | null; + /** + * + * @type {string} + * @memberof ShoppingListRecipeMealplan + */ + servings: string; + /** + * + * @type {string} + * @memberof ShoppingListRecipe + */ + mealplan_note?: string; +} +/** + * + * @export + * @interface ShoppingListRecipeMealplan + */ +export interface ShoppingListRecipeMealplan { + /** + * + * @type {number} + * @memberof ShoppingListRecipeMealplan + */ + id?: number; + /** + * + * @type {string} + * @memberof ShoppingListRecipes + */ + name?: string; + /** + * + * @type {number} + * @memberof ShoppingListRecipeMealplan + */ + recipe?: number | null; + /** + * + * @type {number} + * @memberof ShoppingListRecipeMealplan + */ + mealplan?: number | null; + /** + * + * @type {string} * @memberof ShoppingListRecipeMealplan */ mealplan?: number | null; @@ -2309,12 +2338,6 @@ export interface ShoppingListRecipes { * @memberof ShoppingListRecipes */ id?: number; - /** - * - * @type {string} - * @memberof ShoppingListRecipes - */ - name?: string; /** * * @type {number} @@ -2327,6 +2350,12 @@ export interface ShoppingListRecipes { * @memberof ShoppingListRecipes */ mealplan?: number | null; + /** + * + * @type {string} + * @memberof ShoppingListRecipes + */ + recipe_name?: string; /** * * @type {string} @@ -2530,6 +2559,147 @@ export enum StepTypeEnum { Recipe = 'RECIPE' } +/** + * + * @export + * @interface StepFile + */ +export interface StepFile { + /** + * + * @type {string} + * @memberof StepFile + */ + name: string; + /** + * + * @type {any} + * @memberof StepFile + */ + file?: any; + /** + * + * @type {number} + * @memberof StepFile + */ + id?: number; +} +/** + * + * @export + * @interface StepFood + */ +export interface StepFood { + /** + * + * @type {number} + * @memberof StepFood + */ + id?: number; + /** + * + * @type {string} + * @memberof StepFood + */ + name: string; + /** + * + * @type {string} + * @memberof StepFood + */ + description?: string; + /** + * + * @type {FoodRecipe} + * @memberof StepFood + */ + recipe?: FoodRecipe | null; + /** + * + * @type {boolean} + * @memberof StepFood + */ + ignore_shopping?: boolean; + /** + * + * @type {FoodSupermarketCategory} + * @memberof StepFood + */ + supermarket_category?: FoodSupermarketCategory | null; + /** + * + * @type {string} + * @memberof StepFood + */ + parent?: string; + /** + * + * @type {number} + * @memberof StepFood + */ + numchild?: number; + /** + * + * @type {boolean} + * @memberof StepFood + */ + on_hand?: boolean; +} +/** + * + * @export + * @interface StepIngredients + */ +export interface StepIngredients { + /** + * + * @type {number} + * @memberof StepIngredients + */ + id?: number; + /** + * + * @type {StepFood} + * @memberof StepIngredients + */ + food: StepFood | null; + /** + * + * @type {FoodSupermarketCategory} + * @memberof StepIngredients + */ + unit: FoodSupermarketCategory | null; + /** + * + * @type {string} + * @memberof StepIngredients + */ + amount: string; + /** + * + * @type {string} + * @memberof StepIngredients + */ + note?: string | null; + /** + * + * @type {number} + * @memberof StepIngredients + */ + order?: number; + /** + * + * @type {boolean} + * @memberof StepIngredients + */ + is_header?: boolean; + /** + * + * @type {boolean} + * @memberof StepIngredients + */ + no_amount?: boolean; +} /** * * @export @@ -5237,13 +5407,12 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) }, /** * + * @param {string} [checked] Filter shopping list entries on checked. Valid values are true, false, both and <b>false+</b>.<br> - false+ includes unchecked items and recently completed items. * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. - * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. - * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listShoppingListEntrys: async (id?: number, checked?: string, supermarket?: number, options: any = {}): Promise => { + listShoppingListEntrys: async (checked?: string, id?: number, options: any = {}): Promise => { const localVarPath = `/api/shopping-list-entry/`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -5256,16 +5425,12 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; - if (id !== undefined) { - localVarQueryParameter['id'] = id; - } - if (checked !== undefined) { localVarQueryParameter['checked'] = checked; } - if (supermarket !== undefined) { - localVarQueryParameter['supermarket'] = supermarket; + if (id !== undefined) { + localVarQueryParameter['id'] = id; } @@ -9546,14 +9711,13 @@ export const ApiApiFp = function(configuration?: Configuration) { }, /** * + * @param {string} [checked] Filter shopping list entries on checked. Valid values are true, false, both and <b>false+</b>.<br> - false+ includes unchecked items and recently completed items. * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. - * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. - * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listShoppingListEntrys(id?: number, checked?: string, supermarket?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listShoppingListEntrys(id, checked, supermarket, options); + async listShoppingListEntrys(checked?: string, id?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listShoppingListEntrys(checked, id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -11229,14 +11393,13 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: }, /** * + * @param {string} [checked] Filter shopping list entries on checked. Valid values are true, false, both and <b>false+</b>.<br> - false+ includes unchecked items and recently completed items. * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. - * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. - * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listShoppingListEntrys(id?: number, checked?: string, supermarket?: number, options?: any): AxiosPromise> { - return localVarFp.listShoppingListEntrys(id, checked, supermarket, options).then((request) => request(axios, basePath)); + listShoppingListEntrys(checked?: string, id?: number, options?: any): AxiosPromise> { + return localVarFp.listShoppingListEntrys(checked, id, options).then((request) => request(axios, basePath)); }, /** * @@ -12939,15 +13102,14 @@ export class ApiApi extends BaseAPI { /** * + * @param {string} [checked] Filter shopping list entries on checked. Valid values are true, false, both and <b>false+</b>.<br> - false+ includes unchecked items and recently completed items. * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. - * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. - * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof ApiApi */ - public listShoppingListEntrys(id?: number, checked?: string, supermarket?: number, options?: any) { - return ApiApiFp(this.configuration).listShoppingListEntrys(id, checked, supermarket, options).then((request) => request(this.axios, this.basePath)); + public listShoppingListEntrys(checked?: string, id?: number, options?: any) { + return ApiApiFp(this.configuration).listShoppingListEntrys(checked, id, options).then((request) => request(this.axios, this.basePath)); } /** From fbd17b48feed10628683789981a47adae52e4946 Mon Sep 17 00:00:00 2001 From: smilerz Date: Wed, 13 Oct 2021 07:42:59 -0500 Subject: [PATCH 17/91] Fix after rebase --- cookbook/serializer.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 3b9c3d519..b99e945cb 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -275,6 +275,18 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin): 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 From c5c76cadea8c70f67649ec3ae61188284e612362 Mon Sep 17 00:00:00 2001 From: smilerz Date: Sat, 16 Oct 2021 13:32:27 -0500 Subject: [PATCH 18/91] getUserPreference available for all UserPreferences --- cookbook/migrations/0162_food_inherit.py | 43 +++++++++++++++++++ .../0163_create_food_ignore_fields.py | 24 +++++++++++ cookbook/models.py | 17 +++++++- cookbook/serializer.py | 31 ++++++++----- cookbook/urls.py | 2 +- cookbook/views/api.py | 12 +++--- vue/src/utils/utils.js | 13 ------ 7 files changed, 110 insertions(+), 32 deletions(-) create mode 100644 cookbook/migrations/0162_food_inherit.py create mode 100644 cookbook/migrations/0163_create_food_ignore_fields.py diff --git a/cookbook/migrations/0162_food_inherit.py b/cookbook/migrations/0162_food_inherit.py new file mode 100644 index 000000000..e80fa2be8 --- /dev/null +++ b/cookbook/migrations/0162_food_inherit.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.7 on 2021-10-14 22:36 + +import cookbook.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0161_alter_shoppinglistentry_list_recipe'), + ] + + operations = [ + migrations.CreateModel( + name='FoodParentIgnore', + 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, cookbook.models.PermissionModelMixin), + ), + migrations.AddField( + model_name='food', + name='child_inherit', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userpreference', + name='food_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_parent', + field=models.ManyToManyField(blank=True, related_name='ignore_parent', to='cookbook.FoodParentIgnore'), + ), + ] diff --git a/cookbook/migrations/0163_create_food_ignore_fields.py b/cookbook/migrations/0163_create_food_ignore_fields.py new file mode 100644 index 000000000..3051b141f --- /dev/null +++ b/cookbook/migrations/0163_create_food_ignore_fields.py @@ -0,0 +1,24 @@ +from cookbook.models import FoodParentIgnore +from django.db import migrations + + +def create_ignorefields(apps, schema_editor): + FoodParentIgnore.objects.create(name='Supermarket Category', field='name') + FoodParentIgnore.objects.create(name='Ignore Shopping', field='ignore_shopping') + FoodParentIgnore.objects.create(name='Diet', field='diet') + FoodParentIgnore.objects.create(name='Substitute', field='substitute') + FoodParentIgnore.objects.create(name='Substitute Children', field='substitute_children') + FoodParentIgnore.objects.create(name='Substitute Siblings', field='substitute_siblings') + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0162_food_inherit'), + ] + + operations = [ + migrations.RunPython( + create_ignorefields + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index 3c6764751..ceec8e641 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -329,8 +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) - filter_to_supermarket = models.BooleanField(default=False) - default_delay = models.IntegerField(default=4) + food_inherit = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True) @@ -474,6 +473,18 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi ] +class FoodParentIgnore(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 Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): # exclude fields not implemented yet inherit_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', 'substitute_children', 'substitute_siblings']) @@ -487,6 +498,8 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): ignore_shopping = models.BooleanField(default=False) description = models.TextField(default='', blank=True) on_hand = models.BooleanField(default=False) + child_inherit = models.BooleanField(default=False) + ignore_parent = models.ManyToManyField(FoodParentIgnore, related_name="ignore_parent", blank=True) space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space', _manager_class=TreeManager) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index b99e945cb..195829698 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -12,13 +12,12 @@ 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, - 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.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.templatetags.custom_tags import markdown @@ -163,8 +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', 'default_delay', - 'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share' + 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping' ) @@ -404,7 +402,10 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR class Meta: model = Food - fields = ('id', 'name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category', 'image', 'parent', 'numchild', 'numrecipe', 'on_hand') + fields = ( + 'id', 'name', 'description', 'shopping', 'recipe', 'ignore_shopping', 'supermarket_category', + 'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'child_inherit', 'ignore_parent' + ) read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') @@ -930,3 +931,13 @@ class FoodShoppingUpdateSerializer(serializers.ModelSerializer): class Meta: model = Recipe fields = ['id', 'amount', 'unit', 'delete', ] + + +class FoodParentIgnoreSerializer(serializers.ModelSerializer): + field = serializers.CharField() + name = serializers.CharField() + + class Meta: + model = Recipe + fields = ['id', 'name', 'field', ] + read_only_fields = ('id', 'name', 'field', ) diff --git a/cookbook/urls.py b/cookbook/urls.py index 68ac160b1..e780697af 100644 --- a/cookbook/urls.py +++ b/cookbook/urls.py @@ -19,7 +19,7 @@ router.register(r'automation', api.AutomationViewSet) router.register(r'bookmarklet-import', api.BookmarkletImportViewSet) router.register(r'cook-log', api.CookLogViewSet) router.register(r'food', api.FoodViewSet) -router.register(r'food-inherit-field', api.FoodInheritFieldViewSet) +router.register(r'food-inherit-ignore', api.FoodParentIgnoreViewSet) router.register(r'import-log', api.ImportLogViewSet) router.register(r'ingredient', api.IngredientViewSet) router.register(r'keyword', api.KeywordViewSet) diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 99c234980..52df7fd8b 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -39,7 +39,7 @@ from cookbook.helper.recipe_html_import import get_recipe_from_source from cookbook.helper.recipe_search import get_facet, old_search, search_recipes from cookbook.helper.recipe_url_import import get_from_scraper from cookbook.helper.shopping_helper import shopping_helper -from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField, +from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodParentIgnore, ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory, @@ -50,7 +50,7 @@ from cookbook.provider.local import Local from cookbook.provider.nextcloud import Nextcloud from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer, - CookLogSerializer, FoodInheritFieldSerializer, FoodSerializer, + CookLogSerializer, FoodParentIgnoreSerializer, FoodSerializer, FoodShoppingUpdateSerializer, ImportLogSerializer, IngredientSerializer, KeywordSerializer, MealPlanSerializer, MealTypeSerializer, RecipeBookEntrySerializer, @@ -393,14 +393,14 @@ class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin): pagination_class = DefaultPagination -class FoodInheritFieldViewSet(viewsets.ReadOnlyModelViewSet): - queryset = FoodInheritField.objects - serializer_class = FoodInheritFieldSerializer +class FoodParentIgnoreViewSet(viewsets.ReadOnlyModelViewSet): + queryset = FoodParentIgnore.objects + serializer_class = FoodParentIgnoreSerializer permission_classes = [CustomIsUser] def get_queryset(self): # exclude fields not yet implemented - return Food.inherit_fields + return self.queryset.exclude(field__in=['diet', 'substitute', 'substitute_children', 'substitute_siblings']) class FoodViewSet(viewsets.ModelViewSet, TreeMixin): diff --git a/vue/src/utils/utils.js b/vue/src/utils/utils.js index e4bce0a6e..a0ab5821f 100644 --- a/vue/src/utils/utils.js +++ b/vue/src/utils/utils.js @@ -220,11 +220,6 @@ export const ApiMixin = { return { Models: Models, Actions: Actions, - FoodCreateDefault: function (form) { - form.inherit_ignore = getUserPreference("food_ignore_default") - form.inherit = form.supermarket_category.length > 0 - return form - }, } }, methods: { @@ -536,11 +531,3 @@ const specialCases = { }) }, } - -export const formFunctions = { - FoodCreateDefault: function (form) { - form.fields.filter((x) => x.field === "ignore_inherit")[0].value = getUserPreference("food_ignore_default") - form.fields.filter((x) => x.field === "inherit")[0].value = getUserPreference("food_ignore_default").length > 0 - return form - }, -} From 4377505b14a8ff57eddc45e68af43bb7c2bcb13c Mon Sep 17 00:00:00 2001 From: smilerz Date: Sat, 16 Oct 2021 14:44:42 -0500 Subject: [PATCH 19/91] Fix after rebase --- cookbook/serializer.py | 3 +- vue/src/apps/ChecklistView/ChecklistView.vue | 255 +++++++++++++++++++ vue/src/apps/ModelListView/ModelListView.vue | 2 +- vue/src/components/Ingredient.vue | 236 +++++++++++++++++ vue/vue.config.js | 4 +- 5 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 vue/src/apps/ChecklistView/ChecklistView.vue create mode 100644 vue/src/components/Ingredient.vue diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 195829698..d3c5d032d 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -34,8 +34,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): except KeyError: api_serializer = None # extended values are computationally expensive and not needed in normal circumstances - # another choice is to only return the fields when self.__class__ = serializer and not worry about 'extended' - if self.context['request'] and bool(int(self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer: + if self.context.get('request', False) and bool(int(self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer: return fields else: del fields['image'] diff --git a/vue/src/apps/ChecklistView/ChecklistView.vue b/vue/src/apps/ChecklistView/ChecklistView.vue new file mode 100644 index 000000000..8990ce6e2 --- /dev/null +++ b/vue/src/apps/ChecklistView/ChecklistView.vue @@ -0,0 +1,255 @@ + + + + + + + diff --git a/vue/src/apps/ModelListView/ModelListView.vue b/vue/src/apps/ModelListView/ModelListView.vue index c8a6bec72..e73053af3 100644 --- a/vue/src/apps/ModelListView/ModelListView.vue +++ b/vue/src/apps/ModelListView/ModelListView.vue @@ -64,7 +64,7 @@ import { BootstrapVue } from "bootstrap-vue" import "bootstrap-vue/dist/bootstrap-vue.css" -import { CardMixin, ApiMixin, getConfig, StandardToasts, getUserPreference, makeToast } from "@/utils/utils" +import { CardMixin, ApiMixin, getConfig, StandardToasts } from "@/utils/utils" import GenericInfiniteCards from "@/components/GenericInfiniteCards" import GenericHorizontalCard from "@/components/GenericHorizontalCard" diff --git a/vue/src/components/Ingredient.vue b/vue/src/components/Ingredient.vue new file mode 100644 index 000000000..a15c0ff7d --- /dev/null +++ b/vue/src/components/Ingredient.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/vue/vue.config.js b/vue/vue.config.js index 90cdf7293..25c539f4b 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") From fbe748db62bf4b745a79b10a571886c0acc0441e Mon Sep 17 00:00:00 2001 From: smilerz Date: Tue, 19 Oct 2021 08:35:33 -0500 Subject: [PATCH 20/91] food inherit attributes --- cookbook/forms.py | 6 +- .../0160_delete_shoppinglist_orphans.py | 17 +- cookbook/migrations/0162_food_inherit.py | 43 --- .../0163_create_food_ignore_fields.py | 24 -- cookbook/models.py | 32 +- cookbook/serializer.py | 40 +- cookbook/signals.py | 1 - cookbook/urls.py | 2 +- cookbook/views/api.py | 12 +- vue/src/apps/ModelListView/ModelListView.vue | 2 +- vue/src/utils/models.js | 40 +- vue/src/utils/openapi/api.ts | 356 ++++++++---------- vue/src/utils/utils.js | 13 + 13 files changed, 252 insertions(+), 336 deletions(-) delete mode 100644 cookbook/migrations/0162_food_inherit.py delete mode 100644 cookbook/migrations/0163_create_food_ignore_fields.py diff --git a/cookbook/forms.py b/cookbook/forms.py index 9dae6d9b4..78f29637f 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -45,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', 'shopping_share', 'ingredient_decimals', 'shopping_auto_sync', - 'comments' + 'plan_share', 'ingredient_decimals', 'comments', ) labels = { @@ -93,7 +92,8 @@ class UserPreferenceForm(forms.ModelForm): widgets = { 'plan_share': MultiSelectWidget, - 'shopping_share': MultiSelectWidget + 'shopping_share': MultiSelectWidget, + } diff --git a/cookbook/migrations/0160_delete_shoppinglist_orphans.py b/cookbook/migrations/0160_delete_shoppinglist_orphans.py index 38139f606..349a673ed 100644 --- a/cookbook/migrations/0160_delete_shoppinglist_orphans.py +++ b/cookbook/migrations/0160_delete_shoppinglist_orphans.py @@ -1,13 +1,14 @@ # Generated by Django 3.2.7 on 2021-10-01 22:34 import datetime + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion from django.utils.timezone import utc - from django_scopes import scopes_disabled -from cookbook.models import ShoppingListEntry + +from cookbook.models import FoodInheritField, ShoppingListEntry def delete_orphaned_sle(apps, schema_editor): @@ -16,6 +17,15 @@ def delete_orphaned_sle(apps, schema_editor): 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') + + class Migration(migrations.Migration): dependencies = [ @@ -25,4 +35,5 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(delete_orphaned_sle), + migrations.RunPython(create_inheritfields), ] diff --git a/cookbook/migrations/0162_food_inherit.py b/cookbook/migrations/0162_food_inherit.py deleted file mode 100644 index e80fa2be8..000000000 --- a/cookbook/migrations/0162_food_inherit.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 3.2.7 on 2021-10-14 22:36 - -import cookbook.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('cookbook', '0161_alter_shoppinglistentry_list_recipe'), - ] - - operations = [ - migrations.CreateModel( - name='FoodParentIgnore', - 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, cookbook.models.PermissionModelMixin), - ), - migrations.AddField( - model_name='food', - name='child_inherit', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='userpreference', - name='food_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_parent', - field=models.ManyToManyField(blank=True, related_name='ignore_parent', to='cookbook.FoodParentIgnore'), - ), - ] diff --git a/cookbook/migrations/0163_create_food_ignore_fields.py b/cookbook/migrations/0163_create_food_ignore_fields.py deleted file mode 100644 index 3051b141f..000000000 --- a/cookbook/migrations/0163_create_food_ignore_fields.py +++ /dev/null @@ -1,24 +0,0 @@ -from cookbook.models import FoodParentIgnore -from django.db import migrations - - -def create_ignorefields(apps, schema_editor): - FoodParentIgnore.objects.create(name='Supermarket Category', field='name') - FoodParentIgnore.objects.create(name='Ignore Shopping', field='ignore_shopping') - FoodParentIgnore.objects.create(name='Diet', field='diet') - FoodParentIgnore.objects.create(name='Substitute', field='substitute') - FoodParentIgnore.objects.create(name='Substitute Children', field='substitute_children') - FoodParentIgnore.objects.create(name='Substitute Siblings', field='substitute_siblings') - - -class Migration(migrations.Migration): - - dependencies = [ - ('cookbook', '0162_food_inherit'), - ] - - operations = [ - migrations.RunPython( - create_ignorefields - ), - ] diff --git a/cookbook/models.py b/cookbook/models.py index ceec8e641..f59cb9bee 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -15,9 +15,7 @@ from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile from django.core.validators import MinLengthValidator 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 @@ -329,7 +327,6 @@ 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) - food_inherit = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True) @@ -473,18 +470,6 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi ] -class FoodParentIgnore(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 Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): # exclude fields not implemented yet inherit_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', 'substitute_children', 'substitute_siblings']) @@ -498,8 +483,8 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): ignore_shopping = models.BooleanField(default=False) description = models.TextField(default='', blank=True) on_hand = models.BooleanField(default=False) - child_inherit = models.BooleanField(default=False) - ignore_parent = models.ManyToManyField(FoodParentIgnore, related_name="ignore_parent", blank=True) + 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) @@ -857,8 +842,17 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space') - @classmethod - def list_from_recipe(self, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None): + @ classmethod + 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 associated recipes try: r = recipe or mealplan.recipe except AttributeError: diff --git a/cookbook/serializer.py b/cookbook/serializer.py index d3c5d032d..5a1a888e8 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -12,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 @@ -129,19 +130,12 @@ 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 FoodInheritFieldSerializer(UniqueFieldsMixin, serializers.ModelSerializer): class Meta: model = FoodInheritField fields = ['id', 'name', 'field', ] + read_only_fields = ('id', 'name', 'field', ) class UserPreferenceSerializer(serializers.ModelSerializer): @@ -161,7 +155,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' + 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_ignore_default' ) @@ -367,7 +361,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) recipe = RecipeSimpleSerializer(allow_null=True, required=False) shopping = serializers.SerializerMethodField('get_shopping_status') - ignore_inherit = FoodInheritFieldSerializer(allow_null=True, many=True, required=False) + ignore_inherit = FoodInheritFieldSerializer(allow_null=True, required=False, many=True) recipe_filter = 'steps__ingredients__food' @@ -403,7 +397,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR model = Food fields = ( 'id', 'name', 'description', 'shopping', 'recipe', 'ignore_shopping', 'supermarket_category', - 'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'child_inherit', 'ignore_parent' + 'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'inherit', 'ignore_inherit' ) read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') @@ -930,13 +924,3 @@ class FoodShoppingUpdateSerializer(serializers.ModelSerializer): class Meta: model = Recipe fields = ['id', 'amount', 'unit', 'delete', ] - - -class FoodParentIgnoreSerializer(serializers.ModelSerializer): - field = serializers.CharField() - name = serializers.CharField() - - class Meta: - model = Recipe - fields = ['id', 'name', 'field', ] - read_only_fields = ('id', 'name', 'field', ) diff --git a/cookbook/signals.py b/cookbook/signals.py index 10ca5262c..4a530b027 100644 --- a/cookbook/signals.py +++ b/cookbook/signals.py @@ -48,7 +48,6 @@ def update_step_search_vector(sender, instance=None, created=False, **kwargs): @receiver(post_save, sender=Food) -@skip_signal def update_food_inheritance(sender, instance=None, created=False, **kwargs): if not instance: return diff --git a/cookbook/urls.py b/cookbook/urls.py index e780697af..68ac160b1 100644 --- a/cookbook/urls.py +++ b/cookbook/urls.py @@ -19,7 +19,7 @@ router.register(r'automation', api.AutomationViewSet) router.register(r'bookmarklet-import', api.BookmarkletImportViewSet) router.register(r'cook-log', api.CookLogViewSet) router.register(r'food', api.FoodViewSet) -router.register(r'food-inherit-ignore', api.FoodParentIgnoreViewSet) +router.register(r'food-inherit-field', api.FoodInheritFieldViewSet) router.register(r'import-log', api.ImportLogViewSet) router.register(r'ingredient', api.IngredientViewSet) router.register(r'keyword', api.KeywordViewSet) diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 52df7fd8b..99c234980 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -39,7 +39,7 @@ from cookbook.helper.recipe_html_import import get_recipe_from_source from cookbook.helper.recipe_search import get_facet, old_search, search_recipes from cookbook.helper.recipe_url_import import get_from_scraper from cookbook.helper.shopping_helper import shopping_helper -from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodParentIgnore, +from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory, @@ -50,7 +50,7 @@ from cookbook.provider.local import Local from cookbook.provider.nextcloud import Nextcloud from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer, - CookLogSerializer, FoodParentIgnoreSerializer, FoodSerializer, + CookLogSerializer, FoodInheritFieldSerializer, FoodSerializer, FoodShoppingUpdateSerializer, ImportLogSerializer, IngredientSerializer, KeywordSerializer, MealPlanSerializer, MealTypeSerializer, RecipeBookEntrySerializer, @@ -393,14 +393,14 @@ class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin): pagination_class = DefaultPagination -class FoodParentIgnoreViewSet(viewsets.ReadOnlyModelViewSet): - queryset = FoodParentIgnore.objects - serializer_class = FoodParentIgnoreSerializer +class FoodInheritFieldViewSet(viewsets.ReadOnlyModelViewSet): + queryset = FoodInheritField.objects + serializer_class = FoodInheritFieldSerializer permission_classes = [CustomIsUser] def get_queryset(self): # exclude fields not yet implemented - return self.queryset.exclude(field__in=['diet', 'substitute', 'substitute_children', 'substitute_siblings']) + return Food.inherit_fields class FoodViewSet(viewsets.ModelViewSet, TreeMixin): diff --git a/vue/src/apps/ModelListView/ModelListView.vue b/vue/src/apps/ModelListView/ModelListView.vue index e73053af3..3202ae71a 100644 --- a/vue/src/apps/ModelListView/ModelListView.vue +++ b/vue/src/apps/ModelListView/ModelListView.vue @@ -64,7 +64,7 @@ import { BootstrapVue } from "bootstrap-vue" import "bootstrap-vue/dist/bootstrap-vue.css" -import { CardMixin, ApiMixin, getConfig, StandardToasts } from "@/utils/utils" +import { CardMixin, ApiMixin, getConfig, StandardToasts, getUserPreference } from "@/utils/utils" import GenericInfiniteCards from "@/components/GenericInfiniteCards" import GenericHorizontalCard from "@/components/GenericHorizontalCard" diff --git a/vue/src/utils/models.js b/vue/src/utils/models.js index 7b075d4e4..aec7d8466 100644 --- a/vue/src/utils/models.js +++ b/vue/src/utils/models.js @@ -74,7 +74,18 @@ export class Models { // REQUIRED: unordered array of fields that can be set during create create: { // if not defined partialUpdate will use the same parameters, prepending 'id' - params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category", "on_hand", "inherit", "ignore_inherit"]], + params: [ + [ + "name", + "description", + "recipe", + "ignore_shopping", + "supermarket_category", + "on_hand", + "inherit", + "ignore_inherit", + ], + ], form: { name: { @@ -404,16 +415,35 @@ export class Models { } static RECIPE = { - 'name': i18n.t('Recipe'), - 'apiName': 'Recipe', - 'list': { - 'params': ['query', 'keywords', 'foods', 'units', 'rating', 'books', 'keywordsOr', 'foodsOr', 'booksOr', 'internal', 'random', '_new', 'page', 'pageSize', 'options'], + name: i18n.t("Recipe"), + apiName: "Recipe", + list: { + params: [ + "query", + "keywords", + "foods", + "units", + "rating", + "books", + "keywordsOr", + "foodsOr", + "booksOr", + "internal", + "random", + "_new", + "page", + "pageSize", + "options", + ], // 'config': { // 'foods': {'type': 'string'}, // 'keywords': {'type': 'string'}, // 'books': {'type': 'string'}, // } }, + shopping: { + params: ["id", ["id", "list_recipe", "ingredients", "servings"]], + }, } static USER_NAME = { diff --git a/vue/src/utils/openapi/api.ts b/vue/src/utils/openapi/api.ts index 3687b7f6f..ecec2b9de 100644 --- a/vue/src/utils/openapi/api.ts +++ b/vue/src/utils/openapi/api.ts @@ -239,6 +239,68 @@ export interface Food { * @memberof Food */ on_hand?: boolean; + /** + * + * @type {boolean} + * @memberof Food + */ + inherit?: boolean; + /** + * + * @type {Array} + * @memberof Food + */ + ignore_inherit?: Array | null; +} +/** + * + * @export + * @interface FoodIgnoreInherit + */ +export interface FoodIgnoreInherit { + /** + * + * @type {number} + * @memberof FoodIgnoreInherit + */ + id?: number; + /** + * + * @type {string} + * @memberof FoodIgnoreInherit + */ + name?: string; + /** + * + * @type {string} + * @memberof FoodIgnoreInherit + */ + field?: string; +} +/** + * + * @export + * @interface FoodInheritField + */ +export interface FoodInheritField { + /** + * + * @type {number} + * @memberof FoodInheritField + */ + id?: number; + /** + * + * @type {string} + * @memberof FoodInheritField + */ + name?: string; + /** + * + * @type {string} + * @memberof FoodInheritField + */ + field?: string; } /** * @@ -736,10 +798,10 @@ export interface InlineResponse2004 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2004 */ - results?: Array; + results?: Array; } /** * @@ -896,6 +958,37 @@ export interface InlineResponse2009 { */ results?: Array; } +/** + * + * @export + * @interface InlineResponse2009 + */ +export interface InlineResponse2009 { + /** + * + * @type {number} + * @memberof InlineResponse2009 + */ + count?: number; + /** + * + * @type {string} + * @memberof InlineResponse2009 + */ + next?: string | null; + /** + * + * @type {string} + * @memberof InlineResponse2009 + */ + previous?: string | null; + /** + * + * @type {Array} + * @memberof InlineResponse2009 + */ + results?: Array; +} /** * * @export @@ -1379,10 +1472,10 @@ export interface RecipeBook { icon?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof RecipeBook */ - shared: Array; + shared: Array; /** * * @type {string} @@ -1539,6 +1632,61 @@ export interface RecipeIngredients { */ no_amount?: boolean; } +/** + * + * @export + * @interface RecipeKeywords + */ +export interface RecipeIngredients { + /** + * + * @type {number} + * @memberof RecipeIngredients + */ + id?: number; + /** + * + * @type {IngredientFood} + * @memberof RecipeIngredients + */ + food: IngredientFood | null; + /** + * + * @type {FoodSupermarketCategory} + * @memberof RecipeIngredients + */ + unit: FoodSupermarketCategory | null; + /** + * + * @type {string} + * @memberof RecipeIngredients + */ + amount: string; + /** + * + * @type {string} + * @memberof RecipeIngredients + */ + note?: string | null; + /** + * + * @type {number} + * @memberof RecipeIngredients + */ + order?: number; + /** + * + * @type {boolean} + * @memberof RecipeIngredients + */ + is_header?: boolean; + /** + * + * @type {boolean} + * @memberof RecipeIngredients + */ + no_amount?: boolean; +} /** * * @export @@ -1919,10 +2067,10 @@ export interface ShoppingList { entries: Array | null; /** * - * @type {Array} + * @type {Array} * @memberof ShoppingList */ - shared: Array; + shared: Array; /** * * @type {boolean} @@ -2003,18 +2151,6 @@ export interface ShoppingListEntries { * @memberof ShoppingListEntries */ ingredient?: number | null; - /** - * - * @type {FoodSupermarketCategory} - * @memberof ShoppingListEntries - */ - unit?: FoodSupermarketCategory | null; - /** - * - * @type {number} - * @memberof ShoppingListEntries - */ - ingredient?: number | null; /** * * @type {string} @@ -2326,49 +2462,6 @@ export interface ShoppingListRecipeMealplan { */ mealplan_note?: string; } -/** - * - * @export - * @interface ShoppingListRecipes - */ -export interface ShoppingListRecipes { - /** - * - * @type {number} - * @memberof ShoppingListRecipes - */ - id?: number; - /** - * - * @type {number} - * @memberof ShoppingListRecipes - */ - recipe?: number | null; - /** - * - * @type {number} - * @memberof ShoppingListRecipes - */ - mealplan?: number | null; - /** - * - * @type {string} - * @memberof ShoppingListRecipes - */ - recipe_name?: string; - /** - * - * @type {string} - * @memberof ShoppingListRecipes - */ - servings: string; - /** - * - * @type {string} - * @memberof ShoppingListRecipes - */ - mealplan_note?: string; -} /** * * @export @@ -2559,147 +2652,6 @@ export enum StepTypeEnum { Recipe = 'RECIPE' } -/** - * - * @export - * @interface StepFile - */ -export interface StepFile { - /** - * - * @type {string} - * @memberof StepFile - */ - name: string; - /** - * - * @type {any} - * @memberof StepFile - */ - file?: any; - /** - * - * @type {number} - * @memberof StepFile - */ - id?: number; -} -/** - * - * @export - * @interface StepFood - */ -export interface StepFood { - /** - * - * @type {number} - * @memberof StepFood - */ - id?: number; - /** - * - * @type {string} - * @memberof StepFood - */ - name: string; - /** - * - * @type {string} - * @memberof StepFood - */ - description?: string; - /** - * - * @type {FoodRecipe} - * @memberof StepFood - */ - recipe?: FoodRecipe | null; - /** - * - * @type {boolean} - * @memberof StepFood - */ - ignore_shopping?: boolean; - /** - * - * @type {FoodSupermarketCategory} - * @memberof StepFood - */ - supermarket_category?: FoodSupermarketCategory | null; - /** - * - * @type {string} - * @memberof StepFood - */ - parent?: string; - /** - * - * @type {number} - * @memberof StepFood - */ - numchild?: number; - /** - * - * @type {boolean} - * @memberof StepFood - */ - on_hand?: boolean; -} -/** - * - * @export - * @interface StepIngredients - */ -export interface StepIngredients { - /** - * - * @type {number} - * @memberof StepIngredients - */ - id?: number; - /** - * - * @type {StepFood} - * @memberof StepIngredients - */ - food: StepFood | null; - /** - * - * @type {FoodSupermarketCategory} - * @memberof StepIngredients - */ - unit: FoodSupermarketCategory | null; - /** - * - * @type {string} - * @memberof StepIngredients - */ - amount: string; - /** - * - * @type {string} - * @memberof StepIngredients - */ - note?: string | null; - /** - * - * @type {number} - * @memberof StepIngredients - */ - order?: number; - /** - * - * @type {boolean} - * @memberof StepIngredients - */ - is_header?: boolean; - /** - * - * @type {boolean} - * @memberof StepIngredients - */ - no_amount?: boolean; -} /** * * @export diff --git a/vue/src/utils/utils.js b/vue/src/utils/utils.js index a0ab5821f..afc59fa71 100644 --- a/vue/src/utils/utils.js +++ b/vue/src/utils/utils.js @@ -220,6 +220,11 @@ export const ApiMixin = { return { Models: Models, Actions: Actions, + FoodCreateDefault: function(form) { + form.inherit_ignore = getUserPreference("food_ignore_default") + form.inherit = form.supermarket_category.length > 0 + return form + }, } }, methods: { @@ -531,3 +536,11 @@ const specialCases = { }) }, } + +export const formFunctions = { + FoodCreateDefault: function(form) { + form.fields.filter((x) => x.field === "ignore_inherit")[0].value = getUserPreference("food_ignore_default") + form.fields.filter((x) => x.field === "inherit")[0].value = getUserPreference("food_ignore_default").length > 0 + return form + }, +} From 2a138a852fcbf41cad28fa692d87e79f890c6243 Mon Sep 17 00:00:00 2001 From: smilerz Date: Tue, 19 Oct 2021 13:14:22 -0500 Subject: [PATCH 21/91] inheritance works with object moves --- cookbook/models.py | 1 + cookbook/serializer.py | 25 ++++++++------------ cookbook/signals.py | 1 + vue/src/apps/ModelListView/ModelListView.vue | 14 +++++------ 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/cookbook/models.py b/cookbook/models.py index f59cb9bee..f64bfff32 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -15,6 +15,7 @@ from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile from django.core.validators import MinLengthValidator 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.utils import timezone from django.utils.translation import gettext as _ diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 5a1a888e8..14a1cda42 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -130,12 +130,19 @@ class UserNameSerializer(WritableNestedModelSerializer): fields = ('id', 'username') -class FoodInheritFieldSerializer(UniqueFieldsMixin, serializers.ModelSerializer): +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', ] - read_only_fields = ('id', 'name', 'field', ) class UserPreferenceSerializer(serializers.ModelSerializer): @@ -266,18 +273,6 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin): 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 @@ -361,7 +356,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) recipe = RecipeSimpleSerializer(allow_null=True, required=False) shopping = serializers.SerializerMethodField('get_shopping_status') - ignore_inherit = FoodInheritFieldSerializer(allow_null=True, required=False, many=True) + ignore_inherit = FoodInheritFieldSerializer(many=True) recipe_filter = 'steps__ingredients__food' diff --git a/cookbook/signals.py b/cookbook/signals.py index 4a530b027..10ca5262c 100644 --- a/cookbook/signals.py +++ b/cookbook/signals.py @@ -48,6 +48,7 @@ def update_step_search_vector(sender, instance=None, created=False, **kwargs): @receiver(post_save, sender=Food) +@skip_signal def update_food_inheritance(sender, instance=None, created=False, **kwargs): if not instance: return diff --git a/vue/src/apps/ModelListView/ModelListView.vue b/vue/src/apps/ModelListView/ModelListView.vue index 3202ae71a..eb3c54779 100644 --- a/vue/src/apps/ModelListView/ModelListView.vue +++ b/vue/src/apps/ModelListView/ModelListView.vue @@ -64,7 +64,7 @@ import { BootstrapVue } from "bootstrap-vue" import "bootstrap-vue/dist/bootstrap-vue.css" -import { CardMixin, ApiMixin, getConfig, StandardToasts, getUserPreference } from "@/utils/utils" +import { CardMixin, ApiMixin, getConfig, StandardToasts, getUserPreference, makeToast } from "@/utils/utils" import GenericInfiniteCards from "@/components/GenericInfiniteCards" import GenericHorizontalCard from "@/components/GenericHorizontalCard" @@ -296,11 +296,11 @@ export default { .then((result) => { this.moveUpdateItem(source_id, target_id) // TODO make standard toast - this.makeToast(this.$t("Success"), "Succesfully moved resource", "success") + makeToast(this.$t("Success"), "Succesfully moved resource", "success") }) .catch((err) => { console.log(err) - this.makeToast(this.$t("Error"), err.bodyText, "danger") + makeToast(this.$t("Error"), err.bodyText, "danger") }) }, moveUpdateItem: function (source_id, target_id) { @@ -336,12 +336,12 @@ export default { .then((result) => { this.mergeUpdateItem(source_id, target_id) // TODO make standard toast - this.makeToast(this.$t("Success"), "Succesfully merged resource", "success") + makeToast(this.$t("Success"), "Succesfully merged resource", "success") }) .catch((err) => { //TODO error checking not working with OpenAPI methods console.log("Error", err) - this.makeToast(this.$t("Error"), err.bodyText, "danger") + makeToast(this.$t("Error"), err.bodyText, "danger") }) if (automate) { @@ -390,7 +390,7 @@ export default { }) .catch((err) => { console.log(err) - this.makeToast(this.$t("Error"), err.bodyText, "danger") + makeToast(this.$t("Error"), err.bodyText, "danger") }) }, getRecipes: function (col, item) { @@ -410,7 +410,7 @@ export default { }) .catch((err) => { console.log(err) - this.makeToast(this.$t("Error"), err.bodyText, "danger") + makeToast(this.$t("Error"), err.bodyText, "danger") }) }, refreshThis: function (id) { From f400c7cd7c1ad663c01f9bb5610af2e7795e5fbd Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 21 Oct 2021 17:44:45 -0500 Subject: [PATCH 22/91] 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 731b8f35c..bc28a001b 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 349a673ed..27fb0edb9 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 14a1cda42..f06002692 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 8990ce6e2..fd86f5b15 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 30a3a5d12..f3fe29be6 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 { - + From 5c9f5e0e1ab21b31ef5d890f31b84802a79c2f8f Mon Sep 17 00:00:00 2001 From: smilerz Date: Mon, 25 Oct 2021 11:55:36 -0500 Subject: [PATCH 23/91] fade-enter-active --- vue/src/apps/ChecklistView/ChecklistView.vue | 181 +++++++++---------- vue/src/components/Ingredient.vue | 39 +--- vue/src/components/ShoppingLineItem.vue | 164 +++++++++++++---- 3 files changed, 221 insertions(+), 163 deletions(-) diff --git a/vue/src/apps/ChecklistView/ChecklistView.vue b/vue/src/apps/ChecklistView/ChecklistView.vue index fd86f5b15..0db80ec28 100644 --- a/vue/src/apps/ChecklistView/ChecklistView.vue +++ b/vue/src/apps/ChecklistView/ChecklistView.vue @@ -20,7 +20,7 @@
-
+
@@ -52,80 +52,6 @@ @open-context-menu="openContextMenu" @toggle-checkbox="toggleChecked" > - -
@@ -134,7 +60,15 @@
- These are the settings + + These are the settings
-sort supermarket categories
+ -add supermarket categories
+ - add supermarkets autosync time
+ autosync on/off
+ always restrict supermarket to categories?
+ when restricted or filterd - give visual indication
+ how long to defer shopping - default tomorrow +
@@ -159,25 +93,54 @@ @@ -71,6 +101,10 @@ 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) @@ -78,8 +112,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: [], - components: {}, + mixins: [ApiMixin], + components: { RecipeCard, ContextMenu, ContextMenuItem }, props: { entries: { type: Array, @@ -89,14 +123,15 @@ export default { data() { return { showDetails: false, + recipe: undefined, } }, computed: { formatAmount: function() { - return this.entries[0].amount + return this.formatOneAmount(this.entries[0]) }, formatCategory: function() { - return this.entries[0]?.food?.supermarket_category?.name ?? this.$t("Undefined") + return this.formatOneCategory(this.entries[0]) || this.$t("Undefined") }, formatChecked: function() { return false @@ -109,20 +144,24 @@ export default { } }, formatFood: function() { - return this.entries[0]?.food?.name ?? this.$t("Undefined") + return this.formatOneFood(this.entries[0]) }, formatUnit: function() { - return this.entries[0]?.unit?.name ?? this.$t("Undefined") + return this.formatOneUnit(this.entries[0]) }, formatRecipe: function() { - if (this.entries.length == 1) { - return this.entries[0]?.recipe_mealplan?.name ?? this.$t("Undefined") + if (this.entries?.length == 1) { + return this.formatOneMealPlan(this.entries[0]) || "" } else { - return [this.entries[0]?.recipe_mealplan?.name ?? this.$t("Undefined"), this.$t("CountMore", { count: this.entries.length - 1 })].join(" ") + 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(" ") } }, formatNotes: function() { - return [this.entries[0]?.recipe_mealplan?.mealplan_note, this.entries?.ingredient_note].filter(String).join("\n") + if (this.entries?.length == 1) { + return this.formatOneNote(this.entries[0]) || "" + } + return "" }, }, watch: {}, @@ -146,10 +185,57 @@ export default { // this.saveThis(item, false) // this.$refs.table.refresh() }, + formatOneAmount: function(item) { + return item?.amount ?? 1 + }, + formatOneUnit: function(item) { + return item?.unit?.name ?? "" + }, + formatOneCategory: function(item) { + return item?.food?.supermarket_category?.name + }, + 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) + }) + }, }, } - + From 7c598720d03318cf30de9dba03946cfa5f6096bd Mon Sep 17 00:00:00 2001 From: smilerz Date: Tue, 26 Oct 2021 19:56:44 -0500 Subject: [PATCH 24/91] WIP --- cookbook/forms.py | 7 +- cookbook/helper/shopping_helper.py | 7 +- cookbook/models.py | 53 +- cookbook/serializer.py | 36 +- cookbook/views/views.py | 1 - vue/src/apps/ChecklistView/ChecklistView.vue | 460 ------------------ .../ShoppingListView/ShoppingListView.vue | 298 +++--------- vue/src/components/ShoppingLineItem.vue | 76 +-- vue/vue.config.js | 4 +- 9 files changed, 177 insertions(+), 765 deletions(-) delete mode 100644 vue/src/apps/ChecklistView/ChecklistView.vue diff --git a/cookbook/forms.py b/cookbook/forms.py index 78f29637f..063f79b06 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 bc28a001b..9ea8a6113 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 f64bfff32..84907cfa5 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 f06002692..b5049d6cb 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 0eb2a4852..ab526747e 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 0db80ec28..000000000 --- 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 3e93d30bc..ba764994a 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 25c539f4b..90cdf7293 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") From 1f41fa04a3fb0339683f889239cf30f4d851edc4 Mon Sep 17 00:00:00 2001 From: smilerz Date: Tue, 26 Oct 2021 21:58:06 -0500 Subject: [PATCH 25/91] autosync --- .../ShoppingListView/ShoppingListView.vue | 115 ++++++++++++------ vue/src/components/ShoppingLineItem.vue | 22 +++- 2 files changed, 97 insertions(+), 40 deletions(-) diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue index ba764994a..feef92b9f 100644 --- a/vue/src/apps/ShoppingListView/ShoppingListView.vue +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -1,8 +1,8 @@