add new unit/food from shopping list

This commit is contained in:
smilerz 2021-10-31 13:33:15 -05:00
parent 6e9d609fe0
commit a217db5822
16 changed files with 1061 additions and 1106 deletions

View File

@ -228,6 +228,7 @@ class StorageForm(forms.ModelForm):
}
# TODO: Deprecate
class RecipeBookEntryForm(forms.ModelForm):
prefix = 'bookmark'
@ -480,7 +481,7 @@ class ShoppingPreferenceForm(forms.ModelForm):
fields = (
'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand',
'mealplan_autoinclude_related', 'default_delay', 'filter_to_supermarket'
'mealplan_autoinclude_related', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days'
)
help_texts = {
@ -494,6 +495,7 @@ class ShoppingPreferenceForm(forms.ModelForm):
'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'),
'default_delay': _('Default number of hours to delay a shopping list entry.'),
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
'shopping_recent_days': _('Days of recent shopping list entries to display.'),
}
labels = {
'shopping_share': _('Share Shopping List'),
@ -503,6 +505,7 @@ class ShoppingPreferenceForm(forms.ModelForm):
'mealplan_autoinclude_related': _('Include Related'),
'default_delay': _('Default Delay Hours'),
'filter_to_supermarket': _('Filter to Supermarket'),
'shopping_recent_days': _('Recent Days')
}
widgets = {

View File

@ -79,9 +79,6 @@ def is_object_shared(user, obj):
# share checks for relevant objects
if not user.is_authenticated:
return False
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()

View File

@ -30,7 +30,6 @@ def search_recipes(request, queryset, params):
search_steps = params.getlist('steps', [])
search_units = params.get('units', None)
# TODO I think default behavior should be 'AND' which is how most sites operate with facet/filters based on results
search_keywords_or = str2bool(params.get('keywords_or', True))
search_foods_or = str2bool(params.get('foods_or', True))
search_books_or = str2bool(params.get('books_or', True))
@ -202,20 +201,13 @@ def get_facet(qs=None, request=None, use_cache=True, hash_key=None):
"""
Gets an annotated list from a queryset.
:param qs:
recipe queryset to build facets from
:param request:
the web request that contains the necessary query parameters
:param use_cache:
will find results in cache, if any, and return them or empty list.
will save the list of recipes IDs in the cache for future processing
:param hash_key:
the cache key of the recipe list to process
only evaluated if the use_cache parameter is false
"""
@ -290,7 +282,6 @@ def get_facet(qs=None, request=None, use_cache=True, hash_key=None):
foods = Food.objects.filter(ingredient__step__recipe__in=recipe_list, space=request.space).annotate(recipe_count=Count('ingredient'))
food_a = annotated_qs(foods, root=True, fill=True)
# TODO add rating facet
facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list)
facets['Foods'] = fill_annotated_parents(food_a, food_list)
# TODO add book facet
@ -363,8 +354,6 @@ def annotated_qs(qs, root=False, fill=False):
dirty = False
current_node = node_queue[-1]
depth = current_node.get_depth()
# TODO if node is at the wrong depth for some reason this fails
# either create a 'fix node' page, or automatically move the node to the root
parent_id = current_node.parent
if root and depth > 1 and parent_id not in nodes_list:
parent_id = current_node.parent

View File

@ -15,14 +15,12 @@ from recipes import settings
def shopping_helper(qs, request):
supermarket = request.query_params.get('supermarket', None)
checked = request.query_params.get('checked', 'recent')
user = request.user
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)))
@ -33,8 +31,7 @@ def shopping_helper(qs, request):
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)
week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
supermarket_order = ['checked'] + supermarket_order
@ -51,7 +48,6 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
:param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
"""
# 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"))

View File

@ -2,13 +2,15 @@
import annoying.fields
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField, SearchVector
from django.contrib.postgres.search import SearchVector, SearchVectorField
from django.db import migrations, models
from django.db.models import deletion
from django_scopes import scopes_disabled
from django.utils import translation
from django_scopes import scopes_disabled
from cookbook.managers import DICTIONARY
from cookbook.models import Recipe, Step, Index, PermissionModelMixin, nameSearchField, allSearchFields
from cookbook.models import (Index, PermissionModelMixin, Recipe, Step, allSearchFields,
nameSearchField)
def set_default_search_vector(apps, schema_editor):
@ -16,8 +18,6 @@ def set_default_search_vector(apps, schema_editor):
return
language = DICTIONARY.get(translation.get_language(), 'simple')
with scopes_disabled():
# TODO this approach doesn't work terribly well if multiple languages are in use
# I'm also uncertain about forcing unaccent here
Recipe.objects.all().update(
name_search_vector=SearchVector('name__unaccent', weight='A', config=language),
desc_search_vector=SearchVector('description__unaccent', weight='B', config=language)

View File

@ -157,5 +157,10 @@ class Migration(migrations.Migration):
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),
]

View File

@ -330,7 +330,8 @@ class UserPreference(models.Model, PermissionModelMixin):
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)
default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
shopping_recent_days = models.PositiveIntegerField(default=7)
created_at = models.DateTimeField(auto_now_add=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)

View File

@ -164,7 +164,7 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
'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'
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days'
)

View File

@ -24,7 +24,6 @@ def skip_signal(signal_func):
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):

View File

@ -1,21 +1,18 @@
{% extends "base.html" %}
{% load django_tables2 %}
{% load crispy_forms_tags %}
{% load static %}
{% load i18n %}
{% extends "base.html" %} {% comment %} TODO: Deprecate {% endcomment %} {% load django_tables2 %} {% load crispy_forms_tags %} {% load static %} {% load i18n %} {% block title
%}{% trans "Shopping List" %}{% endblock %} {% block extra_head %} {% include 'include/vue_base.html' %}
{% block title %}{% trans "Shopping List" %}{% endblock %}
<link rel="stylesheet" href="{% static 'css/vue-multiselect-bs4.min.css' %}" />
<script src="{% static 'js/vue-multiselect.min.js' %}"></script>
{% block extra_head %}
{% include 'include/vue_base.html' %}
<script src="{% static 'js/Sortable.min.js' %}"></script>
<script src="{% static 'js/vuedraggable.umd.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/vue-multiselect-bs4.min.css' %}">
<script src="{% static 'js/vue-multiselect.min.js' %}"></script>
<script src="{% static 'js/vue-cookies.js' %}"></script>
<script src="{% static 'js/Sortable.min.js' %}"></script>
<script src="{% static 'js/vuedraggable.umd.min.js' %}"></script>
<script src="{% static 'js/js.cookie.min.js' %}"></script>
<script src="{% static 'js/vue-cookies.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/pretty-checkbox.min.css' %}" />
{% endblock %} {% block content %}
<script src="{% static 'js/js.cookie.min.js' %}"></script>
@ -32,13 +29,24 @@
<b-form-checkbox switch size="lg" v-model="edit_mode"
@change="$forceUpdate()">{% trans 'Edit' %}</b-form-checkbox>
</div>
<div class="row">
<span class="col col-md-9">
<h2>{% trans 'Shopping List' %}</h2>
</span>
<span class="col-md-3">
<a href="{% url 'view_shopping_new' %}" class="float-right">
<button class="btn btn-outline-secondary shadow-none"><i class="fas fa-star"></i> {% trans 'Try the new shopping list' %}</button>
</a>
</span>
<div class="col col-mdd-3 text-right">
<b-form-checkbox switch size="lg" v-model="edit_mode" @change="$forceUpdate()">{% trans 'Edit' %}</b-form-checkbox>
</div>
</div>
<template v-if="shopping_list !== undefined">
<template v-if="shopping_list !== undefined">
<div class="text-center" v-if="loading">
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
<img class="spinner-tandoor"/>
<img class="spinner-tandoor" />
{% else %}
<i class="fas fa-spinner fa-spin fa-8x"></i>
{% endif %}
@ -46,25 +54,16 @@
<div v-else-if="edit_mode">
<div class="row">
<div class="col col-md-6">
<div class="card">
<div class="card-header">
<i class="fa fa-search"></i> {% trans 'Search' %}
</div>
<div class="card-header"><i class="fa fa-search"></i> {% trans 'Search' %}</div>
<div class="card-body">
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes"
placeholder="{% trans 'Search Recipe' %}">
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes" placeholder="{% trans 'Search Recipe' %}" />
<ul class="list-group" style="margin-top: 8px">
<li class="list-group-item" v-for="x in recipes">
<div class="row flex-row" style="padding-left: 0.5vw; padding-right: 0.5vw">
<div class="flex-column flex-fill my-auto"><a v-bind:href="getRecipeUrl(x.id)"
target="_blank"
rel="nofollow norefferer">[[x.name]]</a>
</div>
<div class="flex-column flex-fill my-auto"><a v-bind:href="getRecipeUrl(x.id)" target="_blank" rel="nofollow norefferer">[[x.name]]</a></div>
<div class="flex-column align-self-end">
<button class="btn btn-outline-primary shadow-none"
@click="addRecipeToList(x)"><i
class="fa fa-plus"></i></button>
<button class="btn btn-outline-primary shadow-none" @click="addRecipeToList(x)"><i class="fa fa-plus"></i></button>
</div>
</div>
</li>
@ -75,41 +74,28 @@
<div class="col col-md-6">
<div class="card">
<div class="card-header">
<i class="fa fa-shopping-cart"></i> {% trans 'Shopping Recipes' %}
</div>
<div class="card-header"><i class="fa fa-shopping-cart"></i> {% trans 'Shopping Recipes' %}</div>
<div class="card-body">
<template v-if="shopping_list.recipes.length < 1">
{% trans 'No recipes selected' %}
</template>
<template v-if="shopping_list.recipes.length < 1"> {% trans 'No recipes selected' %} </template>
<template v-else>
<div class="row flex-row my-auto" v-for="x in shopping_list.recipes"
style="margin-top: 1vh!important;">
<div class="flex-column align-self-start " style="margin-right: 0.4vw">
<button class="btn btn-outline-danger" @click="removeRecipeFromList(x)"><i
class="fa fa-trash"></i></button>
<div class="row flex-row my-auto" v-for="x in shopping_list.recipes" style="margin-top: 1vh !important">
<div class="flex-column align-self-start" style="margin-right: 0.4vw">
<button class="btn btn-outline-danger" @click="removeRecipeFromList(x)"><i class="fa fa-trash"></i></button>
</div>
<div class="flex-grow-1 flex-column my-auto"><a v-bind:href="getRecipeUrl(x.recipe)"
target="_blank"
rel="nofollow norefferer">[[x.recipe_name]]</a>
<div class="flex-grow-1 flex-column my-auto">
<a v-bind:href="getRecipeUrl(x.recipe)" target="_blank" rel="nofollow norefferer">[[x.recipe_name]]</a>
</div>
<div class="flex-column align-self-end ">
<div class="flex-column align-self-end">
<div class="input-group input-group-sm my-auto">
<div class="input-group-prepend">
<button class="text-muted btn btn-outline-primary shadow-none"
@click="((x.servings - 1) > 0) ? x.servings -= 1 : 1">-
</button>
<button class="text-muted btn btn-outline-primary shadow-none" @click="((x.servings - 1) > 0) ? x.servings -= 1 : 1">-</button>
</div>
<input class="form-control" type="number" v-model="x.servings">
<input class="form-control" type="number" v-model="x.servings" />
<div class="input-group-append">
<button class="text-muted btn btn-outline-primary shadow-none"
@click="x.servings += 1">
+
</button>
<button class="text-muted btn btn-outline-primary shadow-none" @click="x.servings += 1">+</button>
</div>
</div>
</div>
</div>
</template>
</div>
@ -118,61 +104,57 @@
</div>
<table class="table table-sm" style="margin-top: 1vh">
<template v-for="c in display_categories">
<thead>
<tr>
<th colspan="5">[[c.name]]</th>
</tr>
</thead>
<tbody is="draggable" :list="c.entries" tag="tbody" group="people" @sort="sortEntries"
@change="dragChanged(c, $event)" handle=".handle">
<tr v-for="(element, index) in c.entries" :key="element.id"
v-bind:class="{ 'text-muted': element.checked }">
<tbody is="draggable" :list="c.entries" tag="tbody" group="people" @sort="sortEntries" @change="dragChanged(c, $event)" handle=".handle">
<tr v-for="(element, index) in c.entries" :key="element.id" v-bind:class="{ 'text-muted': element.checked }">
<td class="handle"><i class="fas fa-sort"></i></td>
<td>[[element.amount.toFixed(2)]]</td>
<td>[[element.unit.name]]</td>
<td>[[element.food.name]]</td>
<td>
<button class="btn btn-sm btn-outline-danger" v-if="element.list_recipe === null"
@click="shopping_list.entries = shopping_list.entries.filter(item => item.id !== element.id)">
<i class="fa fa-trash"></i></button>
<button
class="btn btn-sm btn-outline-danger"
v-if="element.list_recipe === null"
@click="shopping_list.entries = shopping_list.entries.filter(item => item.id !== element.id)"
>
<i class="fa fa-trash"></i>
</button>
</td>
</tr>
</tbody>
</template>
</table>
<div class="row" style="text-align: right">
<div class="col">
<b-form-checkbox switch v-model="entry_mode_simple"
@change="$cookies.set('shopping_entry_mode_simple',!entry_mode_simple, -1)">{% trans 'Entry Mode' %}</b-form-checkbox>
<b-form-checkbox switch v-model="entry_mode_simple" @change="$cookies.set('shopping_entry_mode_simple',!entry_mode_simple, -1)"
>{% trans 'Entry Mode' %}</b-form-checkbox
>
</div>
</div>
<div class="row" v-if="entry_mode_simple" style="margin-top: 2vh">
<div class="col-12">
<form v-on:submit.prevent="addSimpleEntry()">
<label for="id_simple_entry">{% trans 'Add Entry' %}</label>
<div class="input-group">
<input id="id_simple_entry" class="form-control" v-model="simple_entry">
<input id="id_simple_entry" class="form-control" v-model="simple_entry" />
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" @click="addSimpleEntry()"><i
class="fa fa-plus"></i>
</button>
<button class="btn btn-outline-secondary" type="button" @click="addSimpleEntry()"><i class="fa fa-plus"></i></button>
</div>
</div>
</form>
</div>
</div>
<div class="row" v-if="!entry_mode_simple" style="margin-top: 2vh">
<div class="col-12 col-lg-3">
<input id="id_advanced_entry" class="form-control" type="number" placeholder="{% trans 'Amount' %}"
v-model="new_entry.amount" ref="new_entry_amount">
<input id="id_advanced_entry" class="form-control" type="number" placeholder="{% trans 'Amount' %}" v-model="new_entry.amount" ref="new_entry_amount" />
</div>
<div class="col-12 col-lg-4">
<multiselect
@ -193,7 +175,8 @@
track-by="name"
:multiple="false"
:loading="units_loading"
@search-change="searchUnits">
@search-change="searchUnits"
>
</multiselect>
</div>
<div class="col-12 col-lg-4">
@ -215,13 +198,13 @@
track-by="name"
:multiple="false"
:loading="foods_loading"
@search-change="searchFoods">
@search-change="searchFoods"
>
</multiselect>
</div>
<div class="col-12 col-lg-1 my-auto text-right">
<button class="btn btn-success btn-lg" @click="addEntry()"><i class="fa fa-plus"></i>
</button>
<button class="btn btn-success btn-lg" @click="addEntry()"><i class="fa fa-plus"></i></button>
</div>
</div>
@ -243,9 +226,9 @@
track-by="id"
:multiple="false"
:loading="supermarkets_loading"
@search-change="searchSupermarket">
@search-change="searchSupermarket"
>
</multiselect>
</div>
</div>
@ -267,34 +250,26 @@
track-by="id"
:multiple="true"
:loading="users_loading"
@search-change="searchUsers">
@search-change="searchUsers"
>
</multiselect>
</div>
</div>
<div class="row">
<div class="col" style="text-align: right; margin-top: 1vh">
<div class="form-group form-check form-group-lg">
<input class="form-check-input" style="zoom:1.3;" type="checkbox"
v-model="shopping_list.finished" id="id_finished">
<label class="form-check-label" style="zoom:1.3;"
for="id_finished"> {% trans 'Finished' %}</label>
</div>
<input class="form-check-input" style="zoom: 1.3" type="checkbox" v-model="shopping_list.finished" id="id_finished" />
<label class="form-check-label" style="zoom: 1.3" for="id_finished"> {% trans 'Finished' %}</label>
</div>
</div>
</div>
</div>
<div v-else>
{% if request.user.userpreference.shopping_auto_sync > 0 %}
<div class="row" v-if="!onLine">
<div class="col col-md-12">
<div class="alert alert-warning" role="alert">
{% trans 'You are offline, shopping list might not syncronize.' %}
</div>
<div class="alert alert-warning" role="alert">{% trans 'You are offline, shopping list might not syncronize.' %}</div>
</div>
</div>
{% endif %}
@ -309,13 +284,10 @@
</tr>
<tr v-for="x in c.entries">
<template v-if="!x.checked">
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
@change="entryChecked(x)">
</td>
<td><input type="checkbox" style="zoom: 1.4" v-model="x.checked" @change="entryChecked(x)" /></td>
<td>[[x.amount.toFixed(2)]]</td>
<td>[[x.unit.name]]</td>
<td>[[x.food.name]] <span class="text-muted" v-if="x.recipes.length > 0">([[x.recipes.join(', ')]])</span>
</td>
<td>[[x.food.name]] <span class="text-muted" v-if="x.recipes.length > 0">([[x.recipes.join(', ')]])</span></td>
</template>
</tr>
</template>
@ -324,70 +296,52 @@
<td colspan="4"></td>
</tr>
<template v-for="c in display_categories">
<tr v-for="x in c.entries" class="text-muted">
<template v-if="x.checked">
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
@change="entryChecked(x)">
</td>
<td><input type="checkbox" style="zoom: 1.4" v-model="x.checked" @change="entryChecked(x)" /></td>
<td>[[x.amount]]</td>
<td>[[x.unit.name]]</td>
<td>[[x.food.name]]</td>
</template>
</tr>
</template>
</table>
</div>
</div>
</div>
<div class="row" style="margin-top: 2vh">
<div class="col" style="text-align: right">
<b-button class="btn btn-info" v-b-modal.id_modal_export><i
class="fas fa-file-export"></i> {% trans 'Export' %}</b-button>
<button class="btn btn-success" @click="updateShoppingList()" v-if="edit_mode"><i
class="fas fa-save"></i> {% trans 'Save' %}
</button>
<b-button class="btn btn-info" v-b-modal.id_modal_export><i class="fas fa-file-export"></i> {% trans 'Export' %}</b-button>
<button class="btn btn-success" @click="updateShoppingList()" v-if="edit_mode"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
</div>
</div>
<br/>
<br/>
<br />
<br />
<b-modal id="id_modal_export" title="{% trans 'Copy/Export' %}">
<div class="row">
<div class="col col-12">
<label>
{% trans 'List Prefix' %}
<input class="form-control" v-model="export_text_prefix">
<input class="form-control" v-model="export_text_prefix" />
</label>
</div>
</div>
<div class="row">
<div class="col col-12">
<b-form-textarea class="form-control" max-rows="8" v-model="export_text">
</b-form-textarea>
<b-form-textarea class="form-control" max-rows="8" v-model="export_text"> </b-form-textarea>
</div>
</div>
</b-modal>
</template>
</template>
{% endblock %} {% block script %}
{% endblock %}
{% block script %}
<script src="{% url 'javascript-catalog' %}"></script>
<script type="application/javascript">
<script src="{% url 'javascript-catalog' %}"></script>
<script type="application/javascript">
let csrftoken = Cookies.get('csrftoken');
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
@ -613,7 +567,6 @@
this.onLine = type === 'online';
},
makeToast: function (title, message, variant = null) {
//TODO remove duplicate function in favor of central one
this.$bvToast.toast(message, {
title: title,
variant: variant,
@ -622,7 +575,7 @@
})
},
loadInitialRecipe: function (recipe, servings) {
servings = 1 //TODO temporary until i can actually fix the servings for this #453
servings = 1
return this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe)).then((response) => {
this.addRecipeToList(response.data, servings)
}).catch((err) => {
@ -735,7 +688,7 @@
})
},
sortEntries: function (a, b) {
//TODO implement me (might be difficult because of computed drag changed stuff)
},
dragChanged: function (category, evt) {
if (evt.added !== undefined) {
@ -871,7 +824,7 @@
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
getRecipeUrl: function (id) { //TODO generic function that can be reused else were
getRecipeUrl: function (id) {
return '{% url 'view_recipe' 123456 %}'.replace('123456', id)
},
addRecipeToList: function (recipe, servings = 1) {
@ -917,7 +870,7 @@
})
},
searchUnits: function (query) { //TODO move to central component
searchUnits: function (query) {
this.units_loading = true
this.$http.get("{% url 'api:unit-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.units = response.data.results;
@ -926,7 +879,7 @@
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
searchFoods: function (query) { //TODO move to central component
searchFoods: function (query) {
this.foods_loading = true
this.$http.get("{% url 'api:food-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.foods = response.data.results
@ -935,17 +888,17 @@
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
addFoodType: function (tag, index) { //TODO move to central component
addFoodType: function (tag, index) {
let new_food = {'name': tag, supermarket_category: null}
this.foods.push(new_food)
this.new_entry.food = new_food
},
addUnitType: function (tag, index) { //TODO move to central component
addUnitType: function (tag, index) {
let new_unit = {'name': tag}
this.units.push(new_unit)
this.new_entry.unit = new_unit
},
searchUsers: function (query) { //TODO move to central component
searchUsers: function (query) {
this.users_loading = true
this.$http.get("{% url 'api:username-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.users = response.data
@ -969,6 +922,6 @@
window.removeEventListener('offline', this.updateOnlineStatus);
}
});
</script>
</script>
{% endblock %}

View File

@ -387,6 +387,7 @@ def user_settings(request):
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']
up.shopping_recent_days = shopping_form.cleaned_data['shopping_recent_days']
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:
up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL
up.save()

View File

@ -25,24 +25,10 @@
<b-form-input min="1" type="number" :description="$t('Amount')" v-model="new_item.amount"></b-form-input>
</div>
<div class="col col-md-3">
<generic-multiselect
@change="new_item.unit = $event.val"
:model="Models.UNIT"
:multiple="false"
:allow_create="false"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="$t('Unit')"
/>
<lookup-input :form="formUnit" :model="Models.UNIT" @change="new_item.unit = $event" :show_label="false" />
</div>
<div class="col col-md-4">
<generic-multiselect
@change="new_item.food = $event.val"
:model="Models.FOOD"
:multiple="false"
:allow_create="false"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="$t('Food')"
/>
<lookup-input :form="formFood" :model="Models.FOOD" @change="new_item.food = $event" :show_label="false" />
</div>
<div class="col col-md-1 ">
<b-button variant="link" class="px-0">
@ -107,7 +93,7 @@
</tr>
</table>
</b-tab>
<!-- settings tab -->
<!-- supermarkets tab -->
<b-tab :title="$t('Supermarkets')">
<div class="row justify-content-center">
<div class="col col-md-5">
@ -183,9 +169,7 @@
</div>
</b-card>
<b-card-sub-title v-if="new_supermarket.editmode" class="pt-0 pb-3"
>Drag categories to change the order categories appear in shopping list.</b-card-sub-title
>
<b-card-sub-title v-if="new_supermarket.editmode" class="pt-0 pb-3">{{ $t("CategoryInstruction") }}</b-card-sub-title>
<b-card
v-if="new_supermarket.editmode && supermarketCategory.length === 0"
class="m-0 p-0 font-weight-bold no-body"
@ -328,6 +312,20 @@
</em>
</div>
</div>
<div class="row">
<div class="col col-md-6">{{ $t("shopping_recent_days") }}</div>
<div class="col col-md-6 text-right">
<input type="number" size="sm" v-model="settings.shopping_recent_days" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">
{{ $t("shopping_recent_days_desc") }}
</em>
</div>
</div>
<div class="row">
<div class="col col-md-6">{{ $t("filter_to_supermarket") }}</div>
<div class="col col-md-6 text-right">
@ -459,6 +457,7 @@ import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
import ShoppingLineItem from "@/components/ShoppingLineItem"
import GenericMultiselect from "@/components/GenericMultiselect"
import GenericPill from "@/components/GenericPill"
import LookupInput from "@/components/Modals/LookupInput"
import draggable from "vuedraggable"
import { ApiMixin, getUserPreference } from "@/utils/utils"
@ -470,7 +469,7 @@ Vue.use(BootstrapVue)
export default {
name: "ShoppingListView",
mixins: [ApiMixin],
components: { ContextMenu, ContextMenuItem, ShoppingLineItem, GenericMultiselect, GenericPill, draggable },
components: { ContextMenu, ContextMenuItem, ShoppingLineItem, GenericMultiselect, GenericPill, draggable, LookupInput },
data() {
return {
@ -492,6 +491,7 @@ export default {
mealplan_autoinclude_related: false,
mealplan_autoexclude_onhand: true,
filter_to_supermarket: false,
shopping_recent_days: 7,
},
new_supermarket: { entrymode: false, value: undefined, editmode: undefined },
new_category: { entrymode: false, value: undefined },
@ -577,6 +577,16 @@ export default {
defaultDelay() {
return getUserPreference("default_delay") || 2
},
formUnit() {
let unit = this.Models.SHOPPING_LIST.create.form.unit
unit.value = this.new_item.unit
return unit
},
formFood() {
let food = this.Models.SHOPPING_LIST.create.form.food
food.value = this.new_item.food
return food
},
itemsDelayed() {
return this.items.filter((x) => !x.delay_until || !Date.parse(x?.delay_until) > new Date(Date.now())).length < this.items.length
},
@ -647,6 +657,10 @@ export default {
},
methods: {
// this.genericAPI inherited from ApiMixin
test(e) {
this.new_item.unit = e
console.log(e, this.new_item, this.formUnit)
},
addItem() {
let api = new ApiApiFactory()
api.createShoppingListEntry(this.new_item)

View File

@ -1,53 +1,40 @@
<template>
<!-- TODO: Deprecate -->
<div id="app">
<div class="row">
<div class="col col-md-12">
<h2>{{ $t('Supermarket') }}</h2>
<h2>{{ $t("Supermarket") }}</h2>
<multiselect v-model="selected_supermarket" track-by="id" label="name"
:options="supermarkets" @input="selectedSupermarketChanged">
</multiselect>
<multiselect v-model="selected_supermarket" track-by="id" label="name" :options="supermarkets" @input="selectedSupermarketChanged"> </multiselect>
<b-button class="btn btn-primary btn-block" style="margin-top: 1vh" v-b-modal.modal-supermarket>
{{ $t('Edit') }}
</b-button>
<b-button class="btn btn-success btn-block" @click="selected_supermarket = {new:true, name:''}"
v-b-modal.modal-supermarket>{{ $t('New') }}
{{ $t("Edit") }}
</b-button>
<b-button class="btn btn-success btn-block" @click="selected_supermarket = { new: true, name: '' }" v-b-modal.modal-supermarket>{{ $t("New") }} </b-button>
</div>
</div>
<hr>
<hr />
<div class="row">
<div class="col col-md-6">
<h4>{{ $t('Categories') }}
<button class="btn btn-success btn-sm" @click="selected_category = {new:true, name:''}"
v-b-modal.modal-category>{{ $t('New') }}
</button>
<h4>
{{ $t("Categories") }}
<button class="btn btn-success btn-sm" @click="selected_category = { new: true, name: '' }" v-b-modal.modal-category>{{ $t("New") }}</button>
</h4>
<draggable :list="selectable_categories" group="supermarket_categories"
:empty-insert-threshold="10">
<draggable :list="selectable_categories" group="supermarket_categories" :empty-insert-threshold="10">
<div v-for="c in selectable_categories" :key="c.id">
<button class="btn btn-block btn-sm btn-primary" style="margin-top: 0.5vh">{{ c.name }}</button>
</div>
</draggable>
</div>
<div class="col col-md-6">
<h4>{{ $t('Selected') }} {{ $t('Categories') }}</h4>
<h4>{{ $t("Selected") }} {{ $t("Categories") }}</h4>
<draggable :list="supermarket_categories" group="supermarket_categories"
:empty-insert-threshold="10" @change="selectedCategoriesChanged">
<draggable :list="supermarket_categories" group="supermarket_categories" :empty-insert-threshold="10" @change="selectedCategoriesChanged">
<div v-for="c in supermarket_categories" :key="c.id">
<button class="btn btn-block btn-sm btn-primary" style="margin-top: 0.5vh">{{ c.name }}</button>
</div>
</draggable>
</div>
@ -56,52 +43,45 @@
<!-- EDIT MODALS -->
<b-modal id="modal-supermarket" v-bind:title="$t('Supermarket')" @ok="supermarketModalOk()">
<label v-if="selected_supermarket !== undefined">
{{ $t('Name') }}
{{ $t("Name") }}
<b-input v-model="selected_supermarket.name"></b-input>
</label>
</b-modal>
<b-modal id="modal-category" v-bind:title="$t('Category')" @ok="categoryModalOk()">
<label v-if="selected_category !== undefined">
{{ $t('Name') }}
{{ $t("Name") }}
<b-input v-model="selected_category.name"></b-input>
</label>
</b-modal>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import 'bootstrap-vue/dist/bootstrap-vue.css'
import "bootstrap-vue/dist/bootstrap-vue.css"
import {ResolveUrlMixin, ToastMixin} from "@/utils/utils";
import { ResolveUrlMixin, ToastMixin } from "@/utils/utils"
import {ApiApiFactory} from "@/utils/openapi/api.ts";
import { ApiApiFactory } from "@/utils/openapi/api.ts"
Vue.use(BootstrapVue)
import draggable from 'vuedraggable'
import draggable from "vuedraggable"
import axios from 'axios'
import Multiselect from "vue-multiselect";
import axios from "axios"
import Multiselect from "vue-multiselect"
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFToken"
axios.defaults.xsrfCookieName = "csrftoken"
export default {
name: 'SupermarketView',
mixins: [
ResolveUrlMixin,
ToastMixin,
],
name: "SupermarketView",
mixins: [ResolveUrlMixin, ToastMixin],
components: {
Multiselect,
draggable
draggable,
},
data() {
return {
@ -111,7 +91,6 @@ export default {
selected_supermarket: {},
selected_category: {},
selectable_categories: [],
supermarket_categories: [],
}
@ -121,81 +100,79 @@ export default {
this.loadInitial()
},
methods: {
loadInitial: function () {
loadInitial: function() {
let apiClient = new ApiApiFactory()
apiClient.listSupermarkets().then(results => {
apiClient.listSupermarkets().then((results) => {
this.supermarkets = results.data
})
apiClient.listSupermarketCategorys().then(results => {
apiClient.listSupermarketCategorys().then((results) => {
this.categories = results.data
this.selectable_categories = this.categories
})
},
selectedCategoriesChanged: function (data) {
selectedCategoriesChanged: function(data) {
let apiClient = new ApiApiFactory()
if ('removed' in data) {
if ("removed" in data) {
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === data.removed.element.id)[0]
apiClient.destroySupermarketCategoryRelation(relation.id)
}
if ('added' in data) {
apiClient.createSupermarketCategoryRelation({
if ("added" in data) {
apiClient
.createSupermarketCategoryRelation({
category: data.added.element,
supermarket: this.selected_supermarket.id, order: 0
}).then(results => {
supermarket: this.selected_supermarket.id,
order: 0,
})
.then((results) => {
this.selected_supermarket.category_to_supermarket.push(results.data)
})
}
if ('moved' in data || 'added' in data) {
this.supermarket_categories.forEach( (element,index) =>{
if ("moved" in data || "added" in data) {
this.supermarket_categories.forEach((element, index) => {
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === element.id)[0]
console.log(relation)
apiClient.partialUpdateSupermarketCategoryRelation(relation.id, {order: index})
apiClient.partialUpdateSupermarketCategoryRelation(relation.id, { order: index })
})
}
},
selectedSupermarketChanged: function (supermarket, id) {
selectedSupermarketChanged: function(supermarket, id) {
this.supermarket_categories = []
this.selectable_categories = this.categories
for (let i of supermarket.category_to_supermarket) {
this.supermarket_categories.push(i.category)
this.selectable_categories = this.selectable_categories.filter(function (el) {
this.selectable_categories = this.selectable_categories.filter(function(el) {
return el.id !== i.category.id
});
})
}
},
supermarketModalOk: function () {
supermarketModalOk: function() {
let apiClient = new ApiApiFactory()
if (this.selected_supermarket.new) {
apiClient.createSupermarket({name: this.selected_supermarket.name}).then(results => {
apiClient.createSupermarket({ name: this.selected_supermarket.name }).then((results) => {
this.selected_supermarket = undefined
this.loadInitial()
})
} else {
apiClient.partialUpdateSupermarket(this.selected_supermarket.id, {name: this.selected_supermarket.name})
apiClient.partialUpdateSupermarket(this.selected_supermarket.id, { name: this.selected_supermarket.name })
}
},
categoryModalOk: function () {
categoryModalOk: function() {
let apiClient = new ApiApiFactory()
if (this.selected_category.new) {
apiClient.createSupermarketCategory({name: this.selected_category.name}).then(results => {
apiClient.createSupermarketCategory({ name: this.selected_category.name }).then((results) => {
this.selected_category = {}
this.loadInitial()
})
} else {
apiClient.partialUpdateSupermarketCategory(this.selected_category.id, {name: this.selected_category.name})
}
}
apiClient.partialUpdateSupermarketCategory(this.selected_category.id, { name: this.selected_category.name })
}
},
},
}
</script>
<style>
</style>
<style></style>

View File

@ -8,7 +8,6 @@
<p v-if="f.type == 'instruction'">{{ f.label }}</p>
<!-- this lookup is single selection -->
<lookup-input v-if="f.type == 'lookup'" :form="f" :model="listModel(f.list)" @change="storeValue" />
<!-- TODO add ability to create new items associated with lookup -->
<!-- TODO: add multi-selection input list -->
<checkbox-input v-if="f.type == 'checkbox'" :label="f.label" :value="f.value" :field="f.field" />
<text-input v-if="f.type == 'text'" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" />

View File

@ -238,7 +238,6 @@
"mealplan_autoinclude_related_desc": "When adding a meal plan to the shopping list (manually or automatically), 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",
@ -260,6 +259,10 @@
"nothing": "Nothing to do",
"err_merge_self": "Cannot merge item with itself",
"show_sql": "Show SQL",
"filter_to_supermarket_desc": "By default, filter shopping list to only include categories for selected supermarket.",
"CategoryName": "Category Name",
"SupermarketName": "Supermarket Name"
"SupermarketName": "Supermarket Name",
"CategoryInstruction": "Drag categories to change the order categories appear in shopping list.",
"shopping_recent_days_desc": "Days of recent shopping list entries to display.",
"shopping_recent_days": "Recent Days"
}

View File

@ -216,6 +216,24 @@ export class Models {
},
create: {
params: [["amount", "unit", "food", "checked"]],
form: {
unit: {
form_field: true,
type: "lookup",
field: "unit",
list: "UNIT",
label: i18n.t("Unit"),
allow_create: true,
},
food: {
form_field: true,
type: "lookup",
field: "food",
list: "FOOD",
label: i18n.t("Food"),
allow_create: true,
},
},
},
}