Fix after rebase
This commit is contained in:
parent
f16e457d14
commit
10a33add75
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
13
cookbook/helper/HelperFunctions.py
Normal file
13
cookbook/helper/HelperFunctions.py
Normal file
@ -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")
|
@ -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,6 +79,10 @@ 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()
|
||||
|
||||
|
||||
|
@ -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.
|
||||
|
40
cookbook/helper/shopping_helper.py
Normal file
40
cookbook/helper/shopping_helper.py
Normal file
@ -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')
|
@ -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
|
||||
|
149
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal file
149
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal file
@ -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),
|
||||
]
|
50
cookbook/migrations/0160_delete_shoppinglist_orphans.py
Normal file
50
cookbook/migrations/0160_delete_shoppinglist_orphans.py
Normal file
@ -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),
|
||||
]
|
19
cookbook/migrations/0161_alter_shoppinglistentry_food.py
Normal file
19
cookbook/migrations/0161_alter_shoppinglistentry_food.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
@ -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)
|
||||
|
||||
|
@ -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', ]
|
||||
|
@ -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._dirty = True
|
||||
instance.skip_signal = True
|
||||
instance.save()
|
||||
finally:
|
||||
del instance._dirty
|
||||
del instance.skip_signal
|
||||
|
||||
# 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)
|
||||
|
@ -339,10 +339,10 @@
|
||||
{% user_prefs request as prefs%}
|
||||
{{ prefs|json_script:'user_preference' }}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{% block script %}
|
||||
|
||||
{% endblock script %}
|
||||
|
||||
<script type="application/javascript">
|
||||
|
@ -1,32 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% load render_bundle from webpack_loader %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% comment %} {% load l10n %} {% endcomment %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content_fluid %}
|
||||
|
||||
<div id="app" >
|
||||
<checklist-view></checklist-view>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
{{ config | json_script:"model_config" }}
|
||||
|
||||
{% if debug %}
|
||||
<script src="{% url 'js_reverse' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/javascript">
|
||||
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
|
||||
</script>
|
||||
|
||||
{% render_bundle 'checklist_view' %}
|
||||
{% endblock %}
|
@ -18,12 +18,23 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="table-container">
|
||||
<span class="col col-md-9">
|
||||
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
|
||||
{% if create_url %}
|
||||
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</h3>
|
||||
</span>
|
||||
{% if request.resolver_match.url_name in 'list_shopping_list' %}
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
{% if filter %}
|
||||
<br/>
|
||||
|
@ -48,6 +48,13 @@
|
||||
aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'Search-Settings' %}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if active_tab == 'shopping' %} active {% endif %}" id="shopping-tab" data-toggle="tab"
|
||||
href="#shopping" role="tab"
|
||||
aria-controls="search"
|
||||
aria-selected="{% if active_tab == 'shopping' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'Shopping-Settings' %}</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
@ -195,6 +202,17 @@
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab-pane {% if active_tab == 'shopping' %} active {% endif %}" id="shopping" role="tabpanel"
|
||||
aria-labelledby="shopping-tab">
|
||||
<h4>{% trans 'Shopping Settings' %}</h4>
|
||||
|
||||
<form action="./#shopping" method="post" id="id_shopping_form">
|
||||
{% csrf_token %}
|
||||
{{ shopping_form|crispy }}
|
||||
<button class="btn btn-success" type="submit" name="shopping_form" id="shopping_form_button"><i
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -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')
|
||||
|
17
cookbook/templates/shoppinglist_template.html
Normal file
17
cookbook/templates/shoppinglist_template.html
Normal file
@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %} {% load render_bundle from webpack_loader %} {% load static %} {% load i18n %} {% block title %} {{ title }} {% endblock %} {% block content_fluid %}
|
||||
|
||||
<div id="app">
|
||||
<shopping-list-view></shopping-list-view>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block script %} {% if debug %}
|
||||
<script src="{% url 'js_reverse' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/javascript">
|
||||
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
|
||||
</script>
|
||||
|
||||
{% render_bundle 'shopping_list_view' %} {% endblock %}
|
@ -1,86 +1,105 @@
|
||||
{% 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 %}
|
||||
|
||||
<nav aria-label="breadcrumb">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
</nav>
|
||||
|
||||
<h3><span class="text-muted">{% trans 'Space:' %}</span> {{ request.space.name }} <small>{% if HOSTED %}
|
||||
<a href="https://tandoor.dev/manage">{% trans 'Manage Subscription' %}</a>{% endif %}</small></h3>
|
||||
<h3>
|
||||
<span class="text-muted">{% trans 'Space:' %}</span> {{ request.space.name }}
|
||||
<small>{% if HOSTED %} <a href="https://tandoor.dev/manage">{% trans 'Manage Subscription' %}</a>{% endif %}</small>
|
||||
</h3>
|
||||
|
||||
<br/>
|
||||
<br />
|
||||
|
||||
<div class="row">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
{% trans 'Number of objects' %}
|
||||
</div>
|
||||
<div class="card-header">{% trans 'Number of objects' %}</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">{% trans 'Recipes' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes }} /
|
||||
{% if request.space.max_recipes > 0 %}
|
||||
{{ request.space.max_recipes }}{% else %}∞{% endif %}</span></li>
|
||||
<li class="list-group-item">{% trans 'Keywords' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.keywords }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Units' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.units }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Ingredients' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.ingredients }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Recipe Imports' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipe_import }}</span></li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Recipes' %} :
|
||||
<span class="badge badge-pill badge-info"
|
||||
>{{ counts.recipes }} / {% if request.space.max_recipes > 0 %} {{ request.space.max_recipes }}{%
|
||||
else %}∞{% endif %}</span
|
||||
>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Keywords' %} : <span class="badge badge-pill badge-info">{{ counts.keywords }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Units' %} : <span class="badge badge-pill badge-info">{{ counts.units }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Ingredients' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.ingredients }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Recipe Imports' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.recipe_import }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
{% trans 'Objects stats' %}
|
||||
</div>
|
||||
<div class="card-header">{% trans 'Objects stats' %}</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">{% trans 'Recipes without Keywords' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span></li>
|
||||
<li class="list-group-item">{% trans 'External Recipes' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_external }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Internal Recipes' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Comments' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.comments }}</span></li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Recipes without Keywords' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'External Recipes' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.recipes_external }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Internal Recipes' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Comments' %} : <span class="badge badge-pill badge-info">{{ counts.comments }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
<div class="row">
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<form action="." method="post">{% csrf_token %} {{ user_name_form|crispy }}</form>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
|
||||
<h4>{% trans 'Members' %} <small class="text-muted">{{ space_users|length }}/
|
||||
{% if request.space.max_users > 0 %}
|
||||
{{ request.space.max_users }}{% else %}∞{% endif %}</small>
|
||||
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"><i
|
||||
class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a>
|
||||
<h4>
|
||||
{% trans 'Members' %}
|
||||
<small class="text-muted"
|
||||
>{{ space_users|length }}/ {% if request.space.max_users > 0 %} {{ request.space.max_users }}{% else
|
||||
%}∞{% endif %}</small
|
||||
>
|
||||
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"
|
||||
><i class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a
|
||||
>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div class="row">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% if space_users %}
|
||||
<table class="table table-bordered">
|
||||
@ -91,30 +110,24 @@
|
||||
</tr>
|
||||
{% for u in space_users %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ u.user.username }}
|
||||
</td>
|
||||
<td>
|
||||
{{ u.user.groups.all |join:", " }}
|
||||
</td>
|
||||
<td>{{ u.user.username }}</td>
|
||||
<td>{{ u.user.groups.all |join:", " }}</td>
|
||||
<td>
|
||||
{% if u.user != request.user %}
|
||||
<div class="input-group mb-3">
|
||||
<select v-model="users['{{ u.pk }}']" class="custom-select form-control"
|
||||
style="height: 44px">
|
||||
<select v-model="users['{{ u.pk }}']" class="custom-select form-control" style="height: 44px">
|
||||
<option value="admin">{% trans 'admin' %}</option>
|
||||
<option value="user">{% trans 'user' %}</option>
|
||||
<option value="guest">{% trans 'guest' %}</option>
|
||||
<option value="remove">{% trans 'remove' %}</option>
|
||||
</select>
|
||||
<span class="input-group-append">
|
||||
<a class="btn btn-warning"
|
||||
:href="editUserUrl({{ u.pk }}, {{ u.space.pk }})">{% trans 'Update' %}</a>
|
||||
<a class="btn btn-warning" :href="editUserUrl({{ u.pk }}, {{ u.space.pk }})"
|
||||
>{% trans 'Update' %}</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
{% trans 'You cannot edit yourself.' %}
|
||||
{% endif %}
|
||||
{% else %} {% trans 'You cannot edit yourself.' %} {% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -123,24 +136,34 @@
|
||||
<p>{% trans 'There are no members in your space yet!' %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<h4>{% trans 'Invite Links' %}</h4>
|
||||
{% render_table invite_links %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
<div class="col col-md-12">
|
||||
<h4>{% trans 'Space Settings' %}</h4>
|
||||
<form action="." method="post">
|
||||
{% csrf_token %}
|
||||
{{ space_form|crispy }}
|
||||
<button class="btn btn-success" type="submit" name="space_form"><i
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %} {% block script %}
|
||||
|
||||
{% block script %}
|
||||
|
||||
<script type="application/javascript">
|
||||
<script type="application/javascript">
|
||||
let app = new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#id_base_container',
|
||||
@ -160,6 +183,6 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
@ -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
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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,8 +32,8 @@ 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", [
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
|
@ -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=''<b>true</b>'') of the provided keywords.')),
|
||||
QueryParam(name='foods_or', description=_(
|
||||
'If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided foods.')),
|
||||
QueryParam(name='books_or', description=_(
|
||||
'If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')),
|
||||
QueryParam(name='internal',
|
||||
description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
|
||||
QueryParam(name='random',
|
||||
description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
|
||||
QueryParam(name='new',
|
||||
description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
|
||||
QueryParam(name='keywords_or', description=_('If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided keywords.')),
|
||||
QueryParam(name='foods_or', description=_('If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided foods.')),
|
||||
QueryParam(name='books_or', description=_('If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')),
|
||||
QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
|
||||
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
|
||||
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
|
||||
]
|
||||
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'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
|
||||
description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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(
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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)))
|
||||
|
23
vue/.gitignore
vendored
23
vue/.gitignore
vendored
@ -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?
|
8
vue/.openapi-generator/FILES
Normal file
8
vue/.openapi-generator/FILES
Normal file
@ -0,0 +1,8 @@
|
||||
.gitignore
|
||||
.npmignore
|
||||
api.ts
|
||||
base.ts
|
||||
common.ts
|
||||
configuration.ts
|
||||
git_push.sh
|
||||
index.ts
|
1
vue/.openapi-generator/VERSION
Normal file
1
vue/.openapi-generator/VERSION
Normal file
@ -0,0 +1 @@
|
||||
5.2.1
|
@ -1,195 +0,0 @@
|
||||
<template>
|
||||
<div id="app" style="margin-bottom: 4vh" v-if="this_model">
|
||||
<generic-modal-form v-if="this_model"
|
||||
:model="this_model"
|
||||
:action="this_action"
|
||||
:item1="this_item"
|
||||
:item2="this_target"
|
||||
:show="show_modal"
|
||||
@finish-action="finishAction"/>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-2 d-none d-md-block">
|
||||
</div>
|
||||
<div class="col-xl-8 col-12">
|
||||
<div class="container-fluid d-flex flex-column flex-grow-1">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6" style="margin-top: 1vh">
|
||||
<h3>
|
||||
<!-- <model-menu/> Replace with a List Menu or a Checklist Menu? -->
|
||||
<span>{{ this.this_model.name }}</span>
|
||||
<span><b-button variant="link" @click="startAction({'action':'new'})"><i
|
||||
class="fas fa-plus-circle fa-2x"></i></b-button></span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
this is where shopping list items go
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
|
||||
import {ApiMixin} from "@/utils/utils";
|
||||
import {StandardToasts, ToastMixin} from "@/utils/utils";
|
||||
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm";
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
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: [ApiMixin, ToastMixin],
|
||||
components: {GenericModalForm},
|
||||
data() {
|
||||
return {
|
||||
// this.Models and this.Actions inherited from ApiMixin
|
||||
items: [],
|
||||
this_model: undefined,
|
||||
model_menu: undefined,
|
||||
this_action: undefined,
|
||||
this_item: {},
|
||||
show_modal: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// value is passed from lists.py
|
||||
let model_config = JSON.parse(document.getElementById('model_config').textContent)
|
||||
this.this_model = this.Models[model_config?.model]
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
startAction: function (e, param) {
|
||||
let source = e?.source ?? {}
|
||||
this.this_item = source
|
||||
// remove recipe from shopping list
|
||||
// mark on-hand
|
||||
// mark puchased
|
||||
// edit shopping category on food
|
||||
// delete food from shopping list
|
||||
// add food to shopping list
|
||||
// add other to shopping list
|
||||
// edit unit conversion
|
||||
// edit purchaseable unit
|
||||
switch (e.action) {
|
||||
case 'delete':
|
||||
this.this_action = this.Actions.DELETE
|
||||
this.show_modal = true
|
||||
break;
|
||||
case 'new':
|
||||
this.this_action = this.Actions.CREATE
|
||||
this.show_modal = true
|
||||
break;
|
||||
case 'edit':
|
||||
this.this_item = e.source
|
||||
this.this_action = this.Actions.UPDATE
|
||||
this.show_modal = true
|
||||
break;
|
||||
}
|
||||
},
|
||||
finishAction: function (e) {
|
||||
let update = undefined
|
||||
switch (e?.action) {
|
||||
case 'save':
|
||||
this.saveThis(e.form_data)
|
||||
break;
|
||||
}
|
||||
if (e !== 'cancel') {
|
||||
switch (this.this_action) {
|
||||
case this.Actions.DELETE:
|
||||
this.deleteThis(this.this_item.id)
|
||||
break;
|
||||
case this.Actions.CREATE:
|
||||
this.saveThis(e.form_data)
|
||||
break;
|
||||
case this.Actions.UPDATE:
|
||||
update = e.form_data
|
||||
update.id = this.this_item.id
|
||||
this.saveThis(update)
|
||||
break;
|
||||
case this.Actions.MERGE:
|
||||
this.mergeThis(this.this_item, e.form_data.target, false)
|
||||
break;
|
||||
case this.Actions.MOVE:
|
||||
this.moveThis(this.this_item.id, e.form_data.target.id)
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.clearState()
|
||||
},
|
||||
getItems: function (params) {
|
||||
this.genericAPI(this.this_model, this.Actions.LIST, params).then((results) => {
|
||||
if (results?.length) {
|
||||
this.items = results.data
|
||||
} else {
|
||||
console.log('no data returned')
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||
})
|
||||
},
|
||||
getThis: function (id) {
|
||||
return this.genericAPI(this.this_model, this.Actions.FETCH, {'id': id})
|
||||
},
|
||||
saveThis: function (thisItem) {
|
||||
if (!thisItem?.id) { // if there is no item id assume it's a new item
|
||||
this.genericAPI(this.this_model, this.Actions.CREATE, thisItem).then((result) => {
|
||||
// this.items = result.data - refresh the list here
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
} else {
|
||||
this.genericAPI(this.this_model, this.Actions.UPDATE, thisItem).then((result) => {
|
||||
// this.refreshThis(thisItem.id) refresh the list here
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
}).catch((err) => {
|
||||
console.log(err, err.response)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
}
|
||||
},
|
||||
getRecipe: function (item) {
|
||||
// change to get pop up card? maybe same for unit and food?
|
||||
},
|
||||
deleteThis: function (id) {
|
||||
this.genericAPI(this.this_model, this.Actions.DELETE, {'id': id}).then((result) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||
})
|
||||
},
|
||||
clearState: function () {
|
||||
this.show_modal = false
|
||||
this.this_action = undefined
|
||||
this.this_item = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
@ -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')
|
@ -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)
|
||||
|
||||
|
@ -1,33 +1,49 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-tabs content-class="mt-3" v-model="current_tab">
|
||||
<b-tabs content-class="mt-3">
|
||||
<b-tab :title="$t('Planner')" active>
|
||||
<div class="row">
|
||||
<div class="col-12 calender-parent">
|
||||
<calendar-view
|
||||
:show-date="showDate" :enable-date-selection="true" class="theme-default"
|
||||
:show-date="showDate"
|
||||
:enable-date-selection="true"
|
||||
class="theme-default"
|
||||
:items="plan_items"
|
||||
:display-period-uom="settings.displayPeriodUom"
|
||||
:period-changed-callback="periodChangedCallback" :enable-drag-drop="true"
|
||||
:period-changed-callback="periodChangedCallback"
|
||||
:enable-drag-drop="true"
|
||||
:item-content-height="item_height"
|
||||
@click-date="createEntryClick" @drop-on-date="moveEntry"
|
||||
@click-date="createEntryClick"
|
||||
@drop-on-date="moveEntry"
|
||||
:display-period-count="settings.displayPeriodCount"
|
||||
:starting-day-of-week="settings.startingDayOfWeek"
|
||||
:display-week-numbers="settings.displayWeekNumbers">
|
||||
:display-week-numbers="settings.displayWeekNumbers"
|
||||
>
|
||||
<template #item="{ value, weekStartDate, top }">
|
||||
<meal-plan-card :value="value" :week-start-date="weekStartDate" :top="top" :detailed="detailed_items"
|
||||
:item_height="item_height" @dragstart="dragged_item = value" @click-item="entryClick"
|
||||
@open-context-menu="openContextMenu"/>
|
||||
<meal-plan-card
|
||||
:value="value"
|
||||
:week-start-date="weekStartDate"
|
||||
:top="top"
|
||||
:detailed="detailed_items"
|
||||
:item_height="item_height"
|
||||
@dragstart="dragged_item = value"
|
||||
@click-item="entryClick"
|
||||
@open-context-menu="openContextMenu"
|
||||
/>
|
||||
</template>
|
||||
<template #header="{ headerProps }">
|
||||
<meal-plan-calender-header ref="header"
|
||||
<meal-plan-calender-header
|
||||
ref="header"
|
||||
:header-props="headerProps"
|
||||
@input="setShowDate" @delete-dragged="deleteEntry(dragged_item)"
|
||||
@input="setShowDate"
|
||||
@delete-dragged="deleteEntry(dragged_item)"
|
||||
@create-new="createEntryClick(new Date())"
|
||||
@set-starting-day-back="setStartingDay(-1)"
|
||||
@set-starting-day-forward="setStartingDay(1)" :i-cal-url="iCalUrl"
|
||||
@set-starting-day-forward="setStartingDay(1)"
|
||||
:i-cal-url="iCalUrl"
|
||||
:options="options"
|
||||
:settings_prop="settings"/>
|
||||
:settings_prop="settings"
|
||||
/>
|
||||
</template>
|
||||
</calendar-view>
|
||||
</div>
|
||||
@ -35,98 +51,73 @@
|
||||
</b-tab>
|
||||
<b-tab :title="$t('Settings')">
|
||||
<div class="row mt-3">
|
||||
<div class="col-12 col-md-3 calender-options">
|
||||
<h5>{{ $t('Planner_Settings') }}</h5>
|
||||
<div class="col-3 calender-options">
|
||||
<h5>{{ $t("Planner_Settings") }}</h5>
|
||||
<b-form>
|
||||
<b-form-group id="UomInput"
|
||||
:label="$t('Period')"
|
||||
:description="$t('Plan_Period_To_Show')"
|
||||
label-for="UomInput">
|
||||
<b-form-select
|
||||
id="UomInput"
|
||||
v-model="settings.displayPeriodUom"
|
||||
:options="options.displayPeriodUom"
|
||||
></b-form-select>
|
||||
<b-form-group id="UomInput" :label="$t('Period')" :description="$t('Plan_Period_To_Show')" label-for="UomInput">
|
||||
<b-form-select id="UomInput" v-model="settings.displayPeriodUom" :options="options.displayPeriodUom"></b-form-select>
|
||||
</b-form-group>
|
||||
<b-form-group id="PeriodInput"
|
||||
:label="$t('Periods')"
|
||||
:description="$t('Plan_Show_How_Many_Periods')"
|
||||
label-for="PeriodInput">
|
||||
<b-form-select
|
||||
id="PeriodInput"
|
||||
v-model="settings.displayPeriodCount"
|
||||
:options="options.displayPeriodCount"
|
||||
></b-form-select>
|
||||
<b-form-group id="PeriodInput" :label="$t('Periods')" :description="$t('Plan_Show_How_Many_Periods')" label-for="PeriodInput">
|
||||
<b-form-select id="PeriodInput" v-model="settings.displayPeriodCount" :options="options.displayPeriodCount"></b-form-select>
|
||||
</b-form-group>
|
||||
<b-form-group id="DaysInput"
|
||||
:label="$t('Starting_Day')"
|
||||
:description="$t('Starting_Day')"
|
||||
label-for="DaysInput">
|
||||
<b-form-select
|
||||
id="DaysInput"
|
||||
v-model="settings.startingDayOfWeek"
|
||||
:options="dayNames"
|
||||
></b-form-select>
|
||||
<b-form-group id="DaysInput" :label="$t('Starting_Day')" :description="$t('Starting_Day')" label-for="DaysInput">
|
||||
<b-form-select id="DaysInput" v-model="settings.startingDayOfWeek" :options="dayNames"></b-form-select>
|
||||
</b-form-group>
|
||||
<b-form-group id="WeekNumInput"
|
||||
:label="$t('Week_Numbers')">
|
||||
<b-form-group id="WeekNumInput" :label="$t('Week_Numbers')">
|
||||
<b-form-checkbox v-model="settings.displayWeekNumbers" name="week_num">
|
||||
{{ $t('Show_Week_Numbers') }}
|
||||
{{ $t("Show_Week_Numbers") }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
</b-form>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-6">
|
||||
<h5>{{ $t('Meal_Types') }}</h5>
|
||||
<div class="col-9 col-lg-6">
|
||||
<h5>{{ $t("Meal_Types") }}</h5>
|
||||
<div>
|
||||
<draggable :list="meal_types" group="meal_types"
|
||||
:empty-insert-threshold="10" handle=".handle" @sort="sortMealTypes()">
|
||||
<draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10" handle=".handle" @sort="sortMealTypes()">
|
||||
<b-card no-body class="mt-1" v-for="(meal_type, index) in meal_types" v-hover :key="meal_type.id">
|
||||
<b-card-header class="p-4">
|
||||
<div class="row">
|
||||
<div class="col-2 handle">
|
||||
<button type="button" class="btn btn-lg shadow-none"><i class="fas fa-arrows-alt-v "></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-lg shadow-none"><i class="fas fa-arrows-alt-v "></i></button>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<h5>{{ meal_type.icon }} {{ meal_type.name }}<span class="float-right text-primary"><i
|
||||
class="fa" v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }"
|
||||
<h5>
|
||||
{{ meal_type.icon }} {{ meal_type.name
|
||||
}}<span class="float-right text-primary"
|
||||
><i
|
||||
class="fa"
|
||||
v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }"
|
||||
@click="editOrSaveMealType(index)"
|
||||
aria-hidden="true"></i></span></h5>
|
||||
aria-hidden="true"
|
||||
></i
|
||||
></span>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</b-card-header>
|
||||
<b-card-body class="p-4" v-if="meal_type.editing">
|
||||
<div class="form-group">
|
||||
<label>{{ $t('Name') }}</label>
|
||||
<input class="form-control"
|
||||
placeholder="Name" v-model="meal_type.name">
|
||||
<label>{{ $t("Name") }}</label>
|
||||
<input class="form-control" placeholder="Name" v-model="meal_type.name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<emoji-input :field="'icon'" :label="$t('Icon')" :value="meal_type.icon"></emoji-input>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ $t('Color') }}</label>
|
||||
<input class="form-control" type="color"
|
||||
name="Name" :value="meal_type.color" @change="meal_type.color = $event.target.value">
|
||||
<label>{{ $t("Color") }}</label>
|
||||
<input class="form-control" type="color" name="Name" :value="meal_type.color" @change="meal_type.color = $event.target.value" />
|
||||
</div>
|
||||
<b-form-checkbox
|
||||
id="checkbox-1"
|
||||
v-model="meal_type.default"
|
||||
name="default_checkbox"
|
||||
class="mb-2">
|
||||
{{ $t('Default') }}
|
||||
<b-form-checkbox id="checkbox-1" v-model="meal_type.default" name="default_checkbox" class="mb-2">
|
||||
{{ $t("Default") }}
|
||||
</b-form-checkbox>
|
||||
<button class="btn btn-danger" @click="deleteMealType(index)">{{ $t('Delete') }}</button>
|
||||
<button class="btn btn-primary float-right" @click="editOrSaveMealType(index)">{{
|
||||
$t('Save')
|
||||
}}
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="deleteMealType(index)">{{ $t("Delete") }}</button>
|
||||
<button class="btn btn-primary float-right" @click="editOrSaveMealType(index)">{{ $t("Save") }}</button>
|
||||
</b-card-body>
|
||||
</b-card>
|
||||
</draggable>
|
||||
<button class="btn btn-success float-right mt-1" @click="newMealType"><i class="fas fa-plus"></i>
|
||||
{{ $t('New_Meal_Type') }}
|
||||
<button class="btn btn-success float-right mt-1" @click="newMealType">
|
||||
<i class="fas fa-plus"></i>
|
||||
{{ $t("New_Meal_Type") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -135,30 +126,66 @@
|
||||
</b-tabs>
|
||||
<ContextMenu ref="menu">
|
||||
<template #menu="{ contextData }">
|
||||
<ContextMenuItem @click="$refs.menu.close();openEntryEdit(contextData.originalItem.entry)">
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{ $t("Edit") }}</a>
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
openEntryEdit(contextData.originalItem.entry)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-pen"></i> {{ $t("Edit") }}</a>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="$refs.menu.close();moveEntryLeft(contextData)">
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i> {{ $t("Move") }}</a>
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
moveEntryLeft(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-arrow-left"></i> {{ $t("Move") }}</a>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="$refs.menu.close();moveEntryRight(contextData)">
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i> {{ $t("Move") }}</a>
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
moveEntryRight(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-arrow-right"></i> {{ $t("Move") }}</a>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="$refs.menu.close();createEntry(contextData.originalItem.entry)">
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-copy"></i> {{ $t("Clone") }}</a>
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
createEntry(contextData.originalItem.entry)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-copy"></i> {{ $t("Clone") }}</a>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="$refs.menu.close();addToShopping(contextData)">
|
||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i> {{ $t("Add_to_Shopping") }}</a>
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
addToShopping(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-shopping-cart"></i> {{ $t("Add_to_Shopping") }}</a>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="$refs.menu.close();deleteEntry(contextData)">
|
||||
<a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
deleteEntry(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2 text-danger" href="#"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
<meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="entryEditing_initial_recipe"
|
||||
:entry-editing_initial_meal_type="entryEditing_initial_meal_type" :modal_title="modal_title"
|
||||
:edit_modal_show="edit_modal_show" @save-entry="editEntry"
|
||||
@delete-entry="deleteEntry" @reload-meal-types="refreshMealTypes"></meal-plan-edit-modal>
|
||||
<meal-plan-edit-modal
|
||||
:entry="entryEditing"
|
||||
:entryEditing_initial_recipe="entryEditing_initial_recipe"
|
||||
:entry-editing_initial_meal_type="entryEditing_initial_meal_type"
|
||||
:modal_title="modal_title"
|
||||
:edit_modal_show="edit_modal_show"
|
||||
@save-entry="editEntry"
|
||||
@delete-entry="deleteEntry"
|
||||
@reload-meal-types="refreshMealTypes"
|
||||
></meal-plan-edit-modal>
|
||||
<template>
|
||||
<div>
|
||||
<b-sidebar id="sidebar-shopping" :title="$t('Shopping_list')" backdrop right shadow="sm">
|
||||
@ -175,40 +202,32 @@
|
||||
</div>
|
||||
<div class="col-12 mt-1" v-if="shopping_list.length > 0">
|
||||
<b-button-group>
|
||||
<b-button variant="success" @click="saveShoppingList"><i class="fas fa-external-link-alt"></i>
|
||||
<b-button variant="success" @click="saveShoppingList"
|
||||
><i class="fas fa-external-link-alt"></i>
|
||||
{{ $t("Open") }}
|
||||
</b-button>
|
||||
<b-button variant="danger" @click="shopping_list = []"><i class="fa fa-trash"></i> {{ $t("Clear") }}
|
||||
</b-button>
|
||||
<b-button variant="danger" @click="shopping_list = []"><i class="fa fa-trash"></i> {{ $t("Clear") }} </b-button>
|
||||
</b-button-group>
|
||||
</div>
|
||||
</div>
|
||||
</b-sidebar>
|
||||
</div>
|
||||
</template>
|
||||
<transition name="slide-fade">
|
||||
<div class="row fixed-bottom p-2 b-1 border-top text-center" style="background:rgba(255,255,255,0.6);"
|
||||
v-if="current_tab === 0">
|
||||
<div class="row fixed-bottom p-2 b-1 border-top text-center" style="background:rgba(255,255,255,0.6);">
|
||||
<div class="col-md-3 col-6">
|
||||
<button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i
|
||||
class="fas fa-calendar-plus"></i> {{ $t('Create') }}
|
||||
</button>
|
||||
<button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i class="fas fa-calendar-plus"></i> {{ $t("Create") }}</button>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i
|
||||
class="fas fa-shopping-cart"></i> {{ $t('Shopping_list') }}
|
||||
</button>
|
||||
<button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i class="fas fa-shopping-cart"></i> {{ $t("Shopping_list") }}</button>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<a class="btn btn-block btn-primary shadow-none" :href="iCalUrl"><i class="fas fa-download"></i>
|
||||
{{ $t('Export_To_ICal') }}
|
||||
<a class="btn btn-block btn-primary shadow-none" :href="iCalUrl"
|
||||
><i class="fas fa-download"></i>
|
||||
{{ $t("Export_To_ICal") }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top
|
||||
:title="$t('Coming_Soon')">
|
||||
{{ $t('Auto_Planner') }}
|
||||
</button>
|
||||
<button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top :title="$t('Coming-Soon')">{{ $t("Auto-Planner") }}</button>
|
||||
</div>
|
||||
<div class="col-12 d-flex justify-content-center mt-2 d-block d-md-none">
|
||||
<b-button-toolbar key-nav aria-label="Toolbar with button groups">
|
||||
@ -217,12 +236,8 @@
|
||||
<b-button v-html="'<'" @click="setStartingDay(-1)"></b-button>
|
||||
</b-button-group>
|
||||
<b-button-group class="mx-1">
|
||||
<b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i class="fas fa-home"></i>
|
||||
</b-button>
|
||||
<b-form-datepicker
|
||||
button-only
|
||||
button-variant="secondary"
|
||||
></b-form-datepicker>
|
||||
<b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i class="fas fa-home"></i> </b-button>
|
||||
<b-form-datepicker button-only button-variant="secondary"></b-form-datepicker>
|
||||
</b-button-group>
|
||||
<b-button-group class="mx-1">
|
||||
<b-button v-html="'>'" @click="setStartingDay(1)"></b-button>
|
||||
@ -231,38 +246,34 @@
|
||||
</b-button-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
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 MealPlanCard from "@/components/MealPlanCard";
|
||||
import MealPlanEditModal from "@/components/MealPlanEditModal";
|
||||
import MealPlanCalenderHeader from "@/components/MealPlanCalenderHeader";
|
||||
import EmojiInput from "@/components/Modals/EmojiInput";
|
||||
|
||||
import ContextMenu from "@/components/ContextMenu/ContextMenu"
|
||||
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
|
||||
import { CalendarView, CalendarMathMixin } from "vue-simple-calendar/src/components/bundle"
|
||||
import Vue from "vue"
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
import MealPlanCard from "../../components/MealPlanCard"
|
||||
import moment from "moment"
|
||||
import { ApiMixin, StandardToasts } from "@/utils/utils"
|
||||
import MealPlanEditModal from "@/components/Modals/MealPlanEditModal"
|
||||
import VueCookies from "vue-cookies"
|
||||
import MealPlanCalenderHeader from "@/components/MealPlanCalenderHeader"
|
||||
import EmojiInput from "../../components/Modals/EmojiInput"
|
||||
import draggable from "vuedraggable"
|
||||
import VueCookies from "vue-cookies";
|
||||
|
||||
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
||||
import {CalendarView, CalendarMathMixin} from "vue-simple-calendar/src/components/bundle";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
|
||||
const {makeToast} = require("@/utils/utils");
|
||||
const { makeToast } = require("@/utils/utils")
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
Vue.use(BootstrapVue)
|
||||
Vue.use(VueCookies)
|
||||
|
||||
let SETTINGS_COOKIE_NAME = 'mealplan_settings'
|
||||
let SETTINGS_COOKIE_NAME = "mealplan_settings"
|
||||
|
||||
export default {
|
||||
name: "MealPlanView",
|
||||
@ -274,29 +285,32 @@ export default {
|
||||
ContextMenuItem,
|
||||
MealPlanCalenderHeader,
|
||||
EmojiInput,
|
||||
draggable
|
||||
draggable,
|
||||
},
|
||||
mixins: [CalendarMathMixin, ApiMixin],
|
||||
data: function () {
|
||||
data: function() {
|
||||
return {
|
||||
showDate: new Date(),
|
||||
plan_entries: [],
|
||||
recipe_viewed: {},
|
||||
settings: {
|
||||
displayPeriodUom: 'week',
|
||||
displayPeriodUom: "week",
|
||||
displayPeriodCount: 2,
|
||||
startingDayOfWeek: 1,
|
||||
displayWeekNumbers: true
|
||||
displayWeekNumbers: true,
|
||||
},
|
||||
dragged_item: null,
|
||||
current_tab: 0,
|
||||
meal_types: [],
|
||||
current_context_menu_item: null,
|
||||
options: {
|
||||
displayPeriodUom: [{text: this.$t('Week'), value: 'week'}, {
|
||||
text: this.$t('Month'),
|
||||
value: 'month'
|
||||
}, {text: this.$t('Year'), value: 'year'}],
|
||||
displayPeriodUom: [
|
||||
{ text: this.$t("Week"), value: "week" },
|
||||
{
|
||||
text: this.$t("Month"),
|
||||
value: "month",
|
||||
},
|
||||
{ text: this.$t("Year"), value: "year" },
|
||||
],
|
||||
displayPeriodCount: [1, 2, 3],
|
||||
entryEditing: {
|
||||
date: null,
|
||||
@ -307,61 +321,61 @@ export default {
|
||||
recipe: null,
|
||||
servings: 1,
|
||||
shared: [],
|
||||
title: '',
|
||||
title_placeholder: this.$t('Title')
|
||||
}
|
||||
title: "",
|
||||
title_placeholder: this.$t("Title"),
|
||||
},
|
||||
},
|
||||
shopping_list: [],
|
||||
current_period: null,
|
||||
entryEditing: {},
|
||||
edit_modal_show: false,
|
||||
ical_url: window.ICAL_URL
|
||||
ical_url: window.ICAL_URL,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
modal_title: function () {
|
||||
modal_title: function() {
|
||||
if (this.entryEditing.id === -1) {
|
||||
return this.$t('Create_Meal_Plan_Entry')
|
||||
return this.$t("Create_Meal_Plan_Entry")
|
||||
} else {
|
||||
return this.$t('Edit_Meal_Plan_Entry')
|
||||
return this.$t("Edit_Meal_Plan_Entry")
|
||||
}
|
||||
},
|
||||
entryEditing_initial_recipe: function () {
|
||||
entryEditing_initial_recipe: function() {
|
||||
if (this.entryEditing.recipe != null) {
|
||||
return [this.entryEditing.recipe]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
entryEditing_initial_meal_type: function () {
|
||||
entryEditing_initial_meal_type: function() {
|
||||
if (this.entryEditing.meal_type != null) {
|
||||
return [this.entryEditing.meal_type]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
plan_items: function () {
|
||||
plan_items: function() {
|
||||
let items = []
|
||||
this.plan_entries.forEach((entry) => {
|
||||
items.push(this.buildItem(entry))
|
||||
})
|
||||
return items
|
||||
},
|
||||
detailed_items: function () {
|
||||
return this.settings.displayPeriodUom === 'week';
|
||||
detailed_items: function() {
|
||||
return this.settings.displayPeriodUom === "week"
|
||||
},
|
||||
dayNames: function () {
|
||||
dayNames: function() {
|
||||
let options = []
|
||||
this.getFormattedWeekdayNames(this.userLocale, "long", 0).forEach((day, index) => {
|
||||
options.push({text: day, value: index})
|
||||
options.push({ text: day, value: index })
|
||||
})
|
||||
return options
|
||||
},
|
||||
userLocale: function () {
|
||||
userLocale: function() {
|
||||
return this.getDefaultBrowserLocale
|
||||
},
|
||||
item_height: function () {
|
||||
if (this.settings.displayPeriodUom === 'week') {
|
||||
item_height: function() {
|
||||
if (this.settings.displayPeriodUom === "week") {
|
||||
return "10rem"
|
||||
} else {
|
||||
return "1.6rem"
|
||||
@ -369,38 +383,37 @@ export default {
|
||||
},
|
||||
iCalUrl() {
|
||||
if (this.current_period !== null) {
|
||||
let start = moment(this.current_period.periodStart).format('YYYY-MM-DD')
|
||||
let end = moment(this.current_period.periodEnd).format('YYYY-MM-DD')
|
||||
let start = moment(this.current_period.periodStart).format("YYYY-MM-DD")
|
||||
let end = moment(this.current_period.periodEnd).format("YYYY-MM-DD")
|
||||
return this.ical_url.replace(/12345/, start).replace(/6789/, end)
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(function () {
|
||||
this.$nextTick(function() {
|
||||
if (this.$cookies.isKey(SETTINGS_COOKIE_NAME)) {
|
||||
this.settings = Object.assign({}, this.settings, this.$cookies.get(SETTINGS_COOKIE_NAME))
|
||||
}
|
||||
})
|
||||
this.$root.$on('change', this.updateEmoji);
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
this.$root.$on("change", this.updateEmoji)
|
||||
},
|
||||
watch: {
|
||||
settings: {
|
||||
handler() {
|
||||
this.$cookies.set(SETTINGS_COOKIE_NAME, this.settings, '360d')
|
||||
this.$cookies.set(SETTINGS_COOKIE_NAME, this.settings, "360d")
|
||||
},
|
||||
deep: true
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addToShopping(entry) {
|
||||
if (entry.originalItem.entry.recipe !== null) {
|
||||
this.shopping_list.push(entry.originalItem.entry)
|
||||
makeToast(this.$t("Success"), this.$t("Added_To_Shopping_List"), 'success')
|
||||
makeToast(this.$t("Success"), this.$t("Added_To_Shopping_List"), "success")
|
||||
} else {
|
||||
makeToast(this.$t("Failure"), this.$t("Cannot_Add_Notes_To_Shopping"), 'danger')
|
||||
makeToast(this.$t("Failure"), this.$t("Cannot_Add_Notes_To_Shopping"), "danger")
|
||||
}
|
||||
},
|
||||
saveShoppingList() {
|
||||
@ -428,29 +441,35 @@ export default {
|
||||
newMealType() {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.createMealType({name: this.$t('Meal_Type')}).then(e => {
|
||||
apiClient
|
||||
.createMealType({ name: "Mealtype" })
|
||||
.then((e) => {
|
||||
this.periodChangedCallback(this.current_period)
|
||||
}).catch(error => {
|
||||
})
|
||||
.catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
|
||||
this.refreshMealTypes()
|
||||
},
|
||||
sortMealTypes() {
|
||||
this.meal_types.forEach(function (element, index) {
|
||||
this.meal_types.forEach(function(element, index) {
|
||||
element.order = index
|
||||
});
|
||||
})
|
||||
let updated = 0
|
||||
this.meal_types.forEach((meal_type) => {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.updateMealType(meal_type.id, meal_type).then(e => {
|
||||
if (updated === (this.meal_types.length - 1)) {
|
||||
apiClient
|
||||
.updateMealType(meal_type.id, meal_type)
|
||||
.then((e) => {
|
||||
if (updated === this.meal_types.length - 1) {
|
||||
this.periodChangedCallback(this.current_period)
|
||||
} else {
|
||||
updated++
|
||||
}
|
||||
}).catch(error => {
|
||||
})
|
||||
.catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
})
|
||||
@ -458,30 +477,36 @@ export default {
|
||||
editOrSaveMealType(index) {
|
||||
let meal_type = this.meal_types[index]
|
||||
if (meal_type.editing) {
|
||||
this.$set(this.meal_types[index], 'editing', false)
|
||||
this.$set(this.meal_types[index], "editing", false)
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.updateMealType(this.meal_types[index].id, this.meal_types[index]).then(e => {
|
||||
apiClient
|
||||
.updateMealType(this.meal_types[index].id, this.meal_types[index])
|
||||
.then((e) => {
|
||||
this.periodChangedCallback(this.current_period)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
}).catch(error => {
|
||||
})
|
||||
.catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
} else {
|
||||
this.$set(this.meal_types[index], 'editing', true)
|
||||
this.$set(this.meal_types[index], "editing", true)
|
||||
}
|
||||
},
|
||||
deleteMealType(index) {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.destroyMealType(this.meal_types[index].id).then(e => {
|
||||
apiClient
|
||||
.destroyMealType(this.meal_types[index].id)
|
||||
.then((e) => {
|
||||
this.periodChangedCallback(this.current_period)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
}).catch(error => {
|
||||
})
|
||||
.catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||
})
|
||||
},
|
||||
updateEmoji: function (field, value) {
|
||||
updateEmoji: function(field, value) {
|
||||
this.meal_types.forEach((meal_type) => {
|
||||
if (meal_type.editing) {
|
||||
meal_type.icon = value
|
||||
@ -501,36 +526,30 @@ export default {
|
||||
}
|
||||
},
|
||||
setShowDate(d) {
|
||||
this.showDate = d;
|
||||
this.showDate = d
|
||||
},
|
||||
createEntryClick(data) {
|
||||
this.entryEditing = this.options.entryEditing
|
||||
this.entryEditing.date = moment(data).format('YYYY-MM-DD')
|
||||
this.entryEditing.date = moment(data).format("YYYY-MM-DD")
|
||||
this.$bvModal.show(`edit-modal`)
|
||||
},
|
||||
findEntry(id) {
|
||||
return this.plan_entries.filter(entry => {
|
||||
return this.plan_entries.filter((entry) => {
|
||||
return entry.id === id
|
||||
})[0]
|
||||
},
|
||||
moveEntry(null_object, target_date, drag_event) {
|
||||
moveEntry(null_object, target_date) {
|
||||
this.plan_entries.forEach((entry) => {
|
||||
if (entry.id === this.dragged_item.id) {
|
||||
if (drag_event.ctrlKey) {
|
||||
let new_entry = Object.assign({}, entry)
|
||||
new_entry.date = target_date
|
||||
this.createEntry(new_entry)
|
||||
} else {
|
||||
entry.date = target_date
|
||||
this.saveEntry(entry)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
moveEntryLeft(data) {
|
||||
this.plan_entries.forEach((entry) => {
|
||||
if (entry.id === data.id) {
|
||||
entry.date = moment(entry.date).subtract(1, 'd')
|
||||
entry.date = moment(entry.date).subtract(1, "d")
|
||||
this.saveEntry(entry)
|
||||
}
|
||||
})
|
||||
@ -538,7 +557,7 @@ export default {
|
||||
moveEntryRight(data) {
|
||||
this.plan_entries.forEach((entry) => {
|
||||
if (entry.id === data.id) {
|
||||
entry.date = moment(entry.date).add(1, 'd')
|
||||
entry.date = moment(entry.date).add(1, "d")
|
||||
this.saveEntry(entry)
|
||||
}
|
||||
})
|
||||
@ -548,9 +567,12 @@ export default {
|
||||
if (entry.id === data.id) {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.destroyMealPlan(entry.id).then(e => {
|
||||
apiClient
|
||||
.destroyMealPlan(entry.id)
|
||||
.then((e) => {
|
||||
list.splice(index, 1)
|
||||
}).catch(error => {
|
||||
})
|
||||
.catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
}
|
||||
@ -566,7 +588,7 @@ export default {
|
||||
openEntryEdit(entry) {
|
||||
this.$bvModal.show(`edit-modal`)
|
||||
this.entryEditing = entry
|
||||
this.entryEditing.date = moment(entry.date).format('YYYY-MM-DD')
|
||||
this.entryEditing.date = moment(entry.date).format("YYYY-MM-DD")
|
||||
if (this.entryEditing.recipe != null) {
|
||||
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
||||
}
|
||||
@ -575,12 +597,14 @@ export default {
|
||||
this.current_period = date
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.listMealPlans({
|
||||
apiClient
|
||||
.listMealPlans({
|
||||
query: {
|
||||
from_date: moment(date.periodStart).format('YYYY-MM-DD'),
|
||||
to_date: moment(date.periodEnd).format('YYYY-MM-DD')
|
||||
}
|
||||
}).then(result => {
|
||||
from_date: moment(date.periodStart).format("YYYY-MM-DD"),
|
||||
to_date: moment(date.periodEnd).format("YYYY-MM-DD"),
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.plan_entries = result.data
|
||||
})
|
||||
this.refreshMealTypes()
|
||||
@ -588,7 +612,7 @@ export default {
|
||||
refreshMealTypes() {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.listMealTypes().then(result => {
|
||||
apiClient.listMealTypes().then((result) => {
|
||||
result.data.forEach((meal_type) => {
|
||||
meal_type.editing = false
|
||||
})
|
||||
@ -600,7 +624,7 @@ export default {
|
||||
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.updateMealPlan(entry.id, entry).catch(error => {
|
||||
apiClient.updateMealPlan(entry.id, entry).catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
},
|
||||
@ -609,35 +633,38 @@ export default {
|
||||
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.createMealPlan(entry).catch(error => {
|
||||
apiClient
|
||||
.createMealPlan(entry)
|
||||
.catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
}).then((entry_result) => {
|
||||
})
|
||||
.then((entry_result) => {
|
||||
this.plan_entries.push(entry_result.data)
|
||||
})
|
||||
},
|
||||
buildItem(plan_entry) {
|
||||
//dirty hack to order items within a day
|
||||
let date = moment(plan_entry.date).add(plan_entry.meal_type.order, 'm')
|
||||
let date = moment(plan_entry.date).add(plan_entry.meal_type.order, "m")
|
||||
return {
|
||||
id: plan_entry.id,
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
entry: plan_entry
|
||||
}
|
||||
entry: plan_entry,
|
||||
}
|
||||
},
|
||||
},
|
||||
directives: {
|
||||
hover: {
|
||||
inserted: function (el) {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
inserted: function(el) {
|
||||
el.addEventListener("mouseenter", () => {
|
||||
el.classList.add("shadow")
|
||||
});
|
||||
el.addEventListener('mouseleave', () => {
|
||||
})
|
||||
el.addEventListener("mouseleave", () => {
|
||||
el.classList.remove("shadow")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -25,14 +25,7 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="col-md-3" style="position: relative; margin-top: 1vh">
|
||||
<b-form-checkbox
|
||||
v-model="show_split"
|
||||
name="check-button"
|
||||
v-if="paginated"
|
||||
class="shadow-none"
|
||||
style="position: relative; top: 50%; transform: translateY(-50%)"
|
||||
switch
|
||||
>
|
||||
<b-form-checkbox v-model="show_split" name="check-button" v-if="paginated" class="shadow-none" style="position: relative; top: 50%; transform: translateY(-50%)" switch>
|
||||
{{ $t("show_split_screen") }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
@ -42,46 +35,19 @@
|
||||
<div class="col" :class="{ 'col-md-6': show_split }">
|
||||
<!-- model isn't paginated and loads in one API call -->
|
||||
<div v-if="!paginated">
|
||||
<generic-horizontal-card
|
||||
v-for="i in items_left"
|
||||
v-bind:key="i.id"
|
||||
:item="i"
|
||||
:model="this_model"
|
||||
@item-action="startAction($event, 'left')"
|
||||
@finish-action="finishAction"
|
||||
/>
|
||||
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'left')" @finish-action="finishAction" />
|
||||
</div>
|
||||
<!-- model is paginated and needs managed -->
|
||||
<generic-infinite-cards v-if="paginated" :card_counts="left_counts" :scroll="show_split" @search="getItems($event, 'left')" @reset="resetList('left')">
|
||||
<template v-slot:cards>
|
||||
<generic-horizontal-card
|
||||
v-for="i in items_left"
|
||||
v-bind:key="i.id"
|
||||
:item="i"
|
||||
:model="this_model"
|
||||
@item-action="startAction($event, 'left')"
|
||||
@finish-action="finishAction"
|
||||
/>
|
||||
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'left')" @finish-action="finishAction" />
|
||||
</template>
|
||||
</generic-infinite-cards>
|
||||
</div>
|
||||
<div class="col col-md-6" v-if="show_split">
|
||||
<generic-infinite-cards
|
||||
v-if="this_model"
|
||||
:card_counts="right_counts"
|
||||
:scroll="show_split"
|
||||
@search="getItems($event, 'right')"
|
||||
@reset="resetList('right')"
|
||||
>
|
||||
<generic-infinite-cards v-if="this_model" :card_counts="right_counts" :scroll="show_split" @search="getItems($event, 'right')" @reset="resetList('right')">
|
||||
<template v-slot:cards>
|
||||
<generic-horizontal-card
|
||||
v-for="i in items_right"
|
||||
v-bind:key="i.id"
|
||||
:item="i"
|
||||
:model="this_model"
|
||||
@item-action="startAction($event, 'right')"
|
||||
@finish-action="finishAction"
|
||||
/>
|
||||
<generic-horizontal-card v-for="i in items_right" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'right')" @finish-action="finishAction" />
|
||||
</template>
|
||||
</generic-infinite-cards>
|
||||
</div>
|
||||
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -25,10 +25,10 @@
|
||||
</div>
|
||||
|
||||
<div style="text-align: center">
|
||||
<keywords-component :recipe="recipe"></keywords-component>
|
||||
<keywords :recipe="recipe"></keywords>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col col-md-3">
|
||||
<div class="row d-flex" style="padding-left: 16px">
|
||||
@ -36,8 +36,10 @@
|
||||
<i class="fas fa-user-clock fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<span class="text-primary"><b>{{ $t('Preparation') }}</b></span><br/>
|
||||
{{ recipe.working_time }} {{ $t('min') }}
|
||||
<span class="text-primary"
|
||||
><b>{{ $t("Preparation") }}</b></span
|
||||
><br />
|
||||
{{ recipe.working_time }} {{ $t("min") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -48,44 +50,13 @@
|
||||
<i class="far fa-clock fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<span class="text-primary"><b>{{ $t('Waiting') }}</b></span><br/>
|
||||
{{ recipe.waiting_time }} {{ $t('min') }}
|
||||
<span class="text-primary"
|
||||
><b>{{ $t("Waiting") }}</b></span
|
||||
><br />
|
||||
{{ recipe.waiting_time }} {{ $t("min") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-4 col-10 mt-2 mt-md-0 mt-lg-0 mt-xl-0">
|
||||
<div class="row d-flex" style="padding-left: 16px">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="fas fa-pizza-slice fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<input
|
||||
style="text-align: right; border-width:0px;border:none; padding:0px; padding-left: 0.5vw; padding-right: 8px; max-width: 80px"
|
||||
value="1" maxlength="3" min="0"
|
||||
type="number" class="form-control form-control-lg" v-model.number="servings"/>
|
||||
</div>
|
||||
<div class="my-auto ">
|
||||
<span class="text-primary"><b><template v-if="recipe.servings_text === ''">{{ $t('Servings') }}</template><template
|
||||
v-else>{{ recipe.servings_text }}</template></b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
|
||||
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && ingredient_count > 0">
|
||||
<div class="card border-primary">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col col-md-8">
|
||||
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<template v-for="s in recipe.steps" v-bind:key="s.id">
|
||||
@ -102,44 +73,59 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
|
||||
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && ingredient_count > 0">
|
||||
<ingredients-card
|
||||
:steps="recipe.steps"
|
||||
:recipe="recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:header="true"
|
||||
@checked-state-changed="updateIngredientCheckedState"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
|
||||
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-4 order-md-2">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
|
||||
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh;"
|
||||
:alt="$t( 'Recipe_Image')" v-if="recipe.image !== null">
|
||||
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh" :alt="$t('Recipe_Image')" v-if="recipe.image !== null" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
|
||||
<div class="col-12">
|
||||
<Nutrition-component :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition-component>
|
||||
<Nutrition :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<template v-if="!recipe.internal">
|
||||
<div v-if="recipe.file_path.includes('.pdf')">
|
||||
<PdfViewer :recipe="recipe"></PdfViewer>
|
||||
</div>
|
||||
<div
|
||||
v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
|
||||
<div v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
|
||||
<ImageViewer :recipe="recipe"></ImageViewer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
||||
<step-component :recipe="recipe" :step="s" :ingredient_factor="ingredient_factor" :index="index" :start_time="start_time"
|
||||
@update-start-time="updateStartTime" @checked-state-changed="updateIngredientCheckedState"></step-component>
|
||||
<Step
|
||||
:recipe="recipe"
|
||||
:step="s"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:index="index"
|
||||
:start_time="start_time"
|
||||
@update-start-time="updateStartTime"
|
||||
@checked-state-changed="updateIngredientCheckedState"
|
||||
></Step>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -147,61 +133,52 @@
|
||||
|
||||
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
|
||||
<div class="col col-md-12">
|
||||
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t('Report Abuse') }}</a>
|
||||
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t("Report Abuse") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import {apiLoadRecipe} from "@/utils/api";
|
||||
import { apiLoadRecipe } from "@/utils/api"
|
||||
|
||||
import Step from "@/components/StepComponent";
|
||||
import RecipeContextMenu from "@/components/RecipeContextMenu";
|
||||
import {ResolveUrlMixin, ToastMixin} from "@/utils/utils";
|
||||
import Ingredient from "@/components/IngredientComponent";
|
||||
import Step from "@/components/Step"
|
||||
import RecipeContextMenu from "@/components/ContextMenu/RecipeContextMenu"
|
||||
import { ResolveUrlMixin, ToastMixin } from "@/utils/utils"
|
||||
import IngredientsCard from "@/components/IngredientsCard"
|
||||
|
||||
import PdfViewer from "@/components/PdfViewer";
|
||||
import ImageViewer from "@/components/ImageViewer";
|
||||
import Nutrition from "@/components/NutritionComponent";
|
||||
import PdfViewer from "@/components/PdfViewer"
|
||||
import ImageViewer from "@/components/ImageViewer"
|
||||
import Nutrition from "@/components/Nutrition"
|
||||
|
||||
import moment from 'moment'
|
||||
import Keywords from "@/components/KeywordsComponent";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import AddRecipeToBook from "@/components/AddRecipeToBook";
|
||||
import RecipeRating from "@/components/RecipeRating";
|
||||
import LastCooked from "@/components/LastCooked";
|
||||
import IngredientComponent from "@/components/IngredientComponent";
|
||||
import StepComponent from "@/components/StepComponent";
|
||||
import KeywordsComponent from "@/components/KeywordsComponent";
|
||||
import NutritionComponent from "@/components/NutritionComponent";
|
||||
import moment from "moment"
|
||||
import Keywords from "@/components/Keywords"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
|
||||
import RecipeRating from "@/components/RecipeRating"
|
||||
import LastCooked from "@/components/LastCooked"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: 'RecipeView',
|
||||
mixins: [
|
||||
ResolveUrlMixin,
|
||||
ToastMixin,
|
||||
],
|
||||
name: "RecipeView",
|
||||
mixins: [ResolveUrlMixin, ToastMixin],
|
||||
components: {
|
||||
LastCooked,
|
||||
RecipeRating,
|
||||
PdfViewer,
|
||||
ImageViewer,
|
||||
IngredientComponent,
|
||||
StepComponent,
|
||||
IngredientsCard,
|
||||
Step,
|
||||
RecipeContextMenu,
|
||||
NutritionComponent,
|
||||
KeywordsComponent,
|
||||
Nutrition,
|
||||
Keywords,
|
||||
LoadingSpinner,
|
||||
AddRecipeToBook,
|
||||
},
|
||||
@ -217,7 +194,7 @@ export default {
|
||||
ingredient_count: 0,
|
||||
servings: 1,
|
||||
start_time: "",
|
||||
share_uid: window.SHARE_UID
|
||||
share_uid: window.SHARE_UID,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -226,8 +203,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
loadRecipe: function (recipe_id) {
|
||||
apiLoadRecipe(recipe_id).then(recipe => {
|
||||
|
||||
apiLoadRecipe(recipe_id).then((recipe) => {
|
||||
if (window.USER_SERVINGS !== 0) {
|
||||
recipe.servings = window.USER_SERVINGS
|
||||
}
|
||||
@ -238,7 +214,7 @@ export default {
|
||||
this.ingredient_count += step.ingredients.length
|
||||
|
||||
for (let ingredient of step.ingredients) {
|
||||
this.$set(ingredient, 'checked', false)
|
||||
this.$set(ingredient, "checked", false)
|
||||
}
|
||||
|
||||
step.time_offset = total_time
|
||||
@ -247,7 +223,7 @@ export default {
|
||||
|
||||
// set start time only if there are any steps with timers (otherwise no timers are rendered)
|
||||
if (total_time > 0) {
|
||||
this.start_time = moment().format('yyyy-MM-DDTHH:mm')
|
||||
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
|
||||
}
|
||||
|
||||
this.recipe = recipe
|
||||
@ -257,21 +233,18 @@ export default {
|
||||
updateStartTime: function (e) {
|
||||
this.start_time = e
|
||||
},
|
||||
|
||||
updateIngredientCheckedState: function (e) {
|
||||
for (let step of this.recipe.steps) {
|
||||
for (let ingredient of step.ingredients) {
|
||||
if (ingredient.id === e.id) {
|
||||
this.$set(ingredient, 'checked', !ingredient.checked)
|
||||
this.$set(ingredient, "checked", !ingredient.checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
|
||||
</style>
|
||||
<style></style>
|
||||
|
805
vue/src/apps/ShoppingListView/ShoppingListView.vue
Normal file
805
vue/src/apps/ShoppingListView/ShoppingListView.vue
Normal file
@ -0,0 +1,805 @@
|
||||
<template>
|
||||
<div id="app" style="margin-bottom: 4vh">
|
||||
<b-alert :show="!online" dismissible class="small float-up" variant="warning">{{ $t("OfflineAlert") }}</b-alert>
|
||||
<div class="row float-top">
|
||||
<div class="offset-md-10 col-md-2 no-gutter text-right">
|
||||
<b-button variant="link" class="px-0">
|
||||
<i class="btn fas fa-plus-circle fa-lg px-0" @click="entrymode = !entrymode" :class="entrymode ? 'text-success' : 'text-muted'" />
|
||||
</b-button>
|
||||
<b-button variant="link" id="id_filters_button" class="px-0">
|
||||
<i class="btn fas fa-filter text-decoration-none fa-lg px-0" :class="filterApplied ? 'text-danger' : 'text-muted'" />
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-tabs content-class="mt-3">
|
||||
<!-- shopping list tab -->
|
||||
<b-tab :title="$t('ShoppingList')" active>
|
||||
<template #title> <b-spinner v-if="loading" type="border" small></b-spinner> {{ $t("ShoppingList") }} </template>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div role="tablist" v-if="items && items.length > 0">
|
||||
<div class="row justify-content-md-center w-75" v-if="entrymode">
|
||||
<div class="col col-md-2 "><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')"
|
||||
/>
|
||||
</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')"
|
||||
/>
|
||||
</div>
|
||||
<div class="col col-md-1 ">
|
||||
<b-button variant="link" class="px-0">
|
||||
<i class="btn fas fa-cart-plus fa-lg px-0 text-success" @click="addItem" />
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(done, x) in Sections" :key="x">
|
||||
<div v-if="x == 'true'">
|
||||
<hr />
|
||||
<hr />
|
||||
<h4>{{ $t("Completed") }}</h4>
|
||||
</div>
|
||||
|
||||
<div v-for="(s, i) in done" :key="i">
|
||||
<h5 v-if="Object.entries(s).length > 0">
|
||||
<b-button
|
||||
class="btn btn-lg text-decoration-none text-dark px-1 py-0 border-0"
|
||||
variant="link"
|
||||
data-toggle="collapse"
|
||||
:href="'#section-' + sectionID(x, i)"
|
||||
:aria-expanded="'true' ? x == 'false' : 'true'"
|
||||
>
|
||||
<i class="fa fa-chevron-right rotate" />
|
||||
{{ i }}
|
||||
</b-button>
|
||||
</h5>
|
||||
|
||||
<div class="collapse" :id="'section-' + sectionID(x, i)" visible role="tabpanel" :class="{ show: x == 'false' }">
|
||||
<!-- passing an array of values to the table grouped by Food -->
|
||||
<div v-for="(entries, x) in Object.entries(s)" :key="x">
|
||||
<ShoppingLineItem :entries="entries[1]" :groupby="group_by" @open-context-menu="openContextMenu" @update-checkbox="updateChecked" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-tab>
|
||||
<!-- recipe tab -->
|
||||
<b-tab :title="$t('Recipes')">
|
||||
<table class="table w-75">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ $t("Meal_Plan") }}</th>
|
||||
<th scope="col">{{ $t("Recipe") }}</th>
|
||||
<th scope="col">{{ $t("Servings") }}</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr v-for="r in Recipes" :key="r.list_recipe">
|
||||
<td>{{ r.recipe_mealplan.name }}</td>
|
||||
<td>{{ r.recipe_mealplan.recipe_name }}</td>
|
||||
<td class="block-inline">
|
||||
<b-form-input min="1" type="number" :debounce="300" :value="r.recipe_mealplan.servings" @input="updateServings($event, r.list_recipe)"></b-form-input>
|
||||
</td>
|
||||
<td>
|
||||
<i class="btn text-danger fas fa-trash fa-lg px-2 border-0" variant="link" :title="$t('Delete')" @click="deleteRecipe($event, r.list_recipe)" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</b-tab>
|
||||
<!-- settings tab -->
|
||||
<b-tab :title="$t('Settings')">
|
||||
<div class="row">
|
||||
<div class="col col-md-4 ">
|
||||
<b-card class="no-body">
|
||||
<div class="row">
|
||||
<div class="col col-md-6">{{ $t("mealplan_autoadd_shopping") }}</div>
|
||||
<div class="col col-md-6 text-right">
|
||||
<input type="checkbox" size="sm" v-model="settings.mealplan_autoadd_shopping" @change="saveSettings" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row sm mb-3">
|
||||
<div class="col">
|
||||
<em class="small text-muted">{{ $t("mealplan_autoadd_shopping_desc") }}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="settings.mealplan_autoadd_shopping">
|
||||
<div class="row">
|
||||
<div class="col col-md-6">{{ $t("mealplan_autoadd_shopping") }}</div>
|
||||
<div class="col col-md-6 text-right">
|
||||
<input type="checkbox" size="sm" v-model="settings.mealplan_autoexclude_onhand" @change="saveSettings" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row sm mb-3">
|
||||
<div class="col">
|
||||
<em class="small text-muted">{{ $t("mealplan_autoadd_shopping_desc") }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="settings.mealplan_autoadd_shopping">
|
||||
<div class="row">
|
||||
<div class="col col-md-6">{{ $t("mealplan_autoinclude_related") }}</div>
|
||||
<div class="col col-md-6 text-right">
|
||||
<input type="checkbox" size="sm" v-model="settings.mealplan_autoinclude_related" @change="saveSettings" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row sm mb-3">
|
||||
<div class="col">
|
||||
<em class="small text-muted">
|
||||
{{ $t("mealplan_autoinclude_related_desc") }}
|
||||
</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-6">{{ $t("shopping_share") }}</div>
|
||||
<div class="col col-md-6 text-right">
|
||||
<generic-multiselect
|
||||
size="sm"
|
||||
@change="
|
||||
settings.shopping_share = $event.val
|
||||
saveSettings()
|
||||
"
|
||||
:model="Models.USER"
|
||||
:initial_selection="settings.shopping_share"
|
||||
label="username"
|
||||
:multiple="true"
|
||||
:allow_create="false"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
:placeholder="$t('User')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row sm mb-3">
|
||||
<div class="col">
|
||||
<em class="small text-muted">{{ $t("shopping_share_desc") }}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-6">{{ $t("shopping_auto_sync") }}</div>
|
||||
<div class="col col-md-6 text-right">
|
||||
<input type="number" size="sm" v-model="settings.shopping_auto_sync" @change="saveSettings" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row sm mb-3">
|
||||
<div class="col">
|
||||
<em class="small text-muted">
|
||||
{{ $t("shopping_auto_sync_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">
|
||||
<input type="checkbox" size="sm" v-model="settings.filter_to_supermarket" @change="saveSettings" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row sm mb-3">
|
||||
<div class="col">
|
||||
<em class="small text-muted">
|
||||
{{ $t("filter_to_supermarket_desc") }}
|
||||
</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-6">{{ $t("default_delay") }}</div>
|
||||
<div class="col col-md-6 text-right">
|
||||
<input type="number" size="sm" min="1" v-model="settings.default_delay" @change="saveSettings" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row sm mb-3">
|
||||
<div class="col">
|
||||
<em class="small text-muted">
|
||||
{{ $t("default_delay_desc") }}
|
||||
</em>
|
||||
</div>
|
||||
</div>
|
||||
</b-card>
|
||||
</div>
|
||||
<div class="col col-md-8">
|
||||
<b-card class=" no-body">
|
||||
put the supermarket stuff here<br />
|
||||
-add supermarkets<br />
|
||||
-add supermarket categories<br />
|
||||
-sort supermarket categories<br />
|
||||
</b-card>
|
||||
</div>
|
||||
</div>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
<b-popover target="id_filters_button" triggers="click" placement="bottomleft" :title="$t('Filters')">
|
||||
<div>
|
||||
<b-form-group v-bind:label="$t('GroupBy')" label-for="popover-input-1" label-cols="6" class="mb-1">
|
||||
<b-form-select v-model="group_by" :options="group_by_choices" size="sm"></b-form-select>
|
||||
</b-form-group>
|
||||
<b-form-group v-bind:label="$t('Supermarket')" label-for="popover-input-2" label-cols="6" class="mb-1">
|
||||
<b-form-select v-model="selected_supermarket" :options="supermarkets" text-field="name" value-field="id" size="sm"></b-form-select>
|
||||
</b-form-group>
|
||||
<b-form-group v-bind:label="$t('ShowDelayed')" label-for="popover-input-3" content-cols="1" class="mb-1">
|
||||
<b-form-checkbox v-model="show_delay"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
<b-form-group v-bind:label="$t('ShowUncategorizedFood')" label-for="popover-input-4" content-cols="1" class="mb-1" v-if="!selected_supermarket">
|
||||
<b-form-checkbox v-model="show_undefined_categories"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
<b-form-group v-bind:label="$t('SupermarketCategoriesOnly')" label-for="popover-input-5" content-cols="1" class="mb-1" v-if="selected_supermarket">
|
||||
<b-form-checkbox v-model="supermarket_categories_only"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
</div>
|
||||
<div class="row " style="margin-top: 1vh;min-width:300px">
|
||||
<div class="col-12 " style="text-align: right;">
|
||||
<b-button size="sm" variant="primary" class="mx-1" @click="resetFilters">{{ $t("Reset") }} </b-button>
|
||||
<b-button size="sm" variant="secondary" class="mr-3" @click="$root.$emit('bv::hide::popover')">{{ $t("Close") }} </b-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-popover>
|
||||
<ContextMenu ref="menu">
|
||||
<template #menu="{ contextData }">
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
moveEntry($event, contextData)
|
||||
$refs.menu.close()
|
||||
"
|
||||
>
|
||||
<b-form-group label-cols="6" content-cols="6" class="text-nowrap m-0 mr-2">
|
||||
<template #label>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-cubes"></i> {{ $t("MoveCategory") }}</a>
|
||||
</template>
|
||||
<span @click.prevent.stop @mouseup.prevent.stop>
|
||||
<!-- would like to hide the dropdown value and only display value in button - not sure how to do that -->
|
||||
<b-form-select class="mt-2 border-0" :options="shopping_categories" text-field="name" value-field="id" v-model="shopcat"></b-form-select>
|
||||
</span>
|
||||
</b-form-group>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
onHand(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-clipboard-check"></i> {{ $t("OnHand") }}</a>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
delayThis(contextData)
|
||||
"
|
||||
>
|
||||
<b-form-group label-cols="10" content-cols="2" class="text-nowrap m-0 mr-2">
|
||||
<template #label>
|
||||
<a class="dropdown-item p-2" href="#"><i class="far fa-hourglass"></i> {{ $t("DelayFor", { hours: delay }) }}</a>
|
||||
</template>
|
||||
<div @click.prevent.stop>
|
||||
<b-form-input class="mt-2" min="0" type="number" v-model="delay"></b-form-input>
|
||||
</div>
|
||||
</b-form-group>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
ignoreThis(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-ban"></i> {{ $t("IgnoreThis", { food: foodName(contextData) }) }}</a>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
deleteThis(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2 text-danger" href="#"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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 ShoppingLineItem from "@/components/ShoppingLineItem"
|
||||
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||
|
||||
import { ApiMixin, getUserPreference } from "@/utils/utils"
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
import { StandardToasts, makeToast } from "@/utils/utils"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "ShoppingListView",
|
||||
mixins: [ApiMixin],
|
||||
components: { ContextMenu, ContextMenuItem, ShoppingLineItem, GenericMultiselect },
|
||||
|
||||
data() {
|
||||
return {
|
||||
// this.Models and this.Actions inherited from ApiMixin
|
||||
items: [],
|
||||
group_by: "category",
|
||||
group_by_choices: ["created_by", "category", "recipe"],
|
||||
supermarkets: [],
|
||||
shopping_categories: [],
|
||||
selected_supermarket: undefined,
|
||||
show_undefined_categories: true,
|
||||
supermarket_categories_only: false,
|
||||
shopcat: null,
|
||||
delay: 0,
|
||||
settings: {
|
||||
shopping_auto_sync: 0,
|
||||
default_delay: 4,
|
||||
mealplan_autoadd_shopping: false,
|
||||
mealplan_autoinclude_related: false,
|
||||
mealplan_autoexclude_onhand: true,
|
||||
filter_to_supermarket: false,
|
||||
},
|
||||
|
||||
autosync_id: undefined,
|
||||
auto_sync_running: false,
|
||||
show_delay: false,
|
||||
show_modal: false,
|
||||
fields: ["checked", "amount", "category", "unit", "food", "recipe", "details"],
|
||||
loading: true,
|
||||
entrymode: false,
|
||||
new_item: { amount: 1, unit: undefined, food: undefined },
|
||||
online: true,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
Sections() {
|
||||
function getKey(item, group_by, x) {
|
||||
switch (group_by) {
|
||||
case "category":
|
||||
return item?.food?.supermarket_category?.name ?? x
|
||||
case "created_by":
|
||||
return item?.created_by?.username ?? x
|
||||
case "recipe":
|
||||
return item?.recipe_mealplan?.recipe_name ?? x
|
||||
}
|
||||
}
|
||||
|
||||
let shopping_list = this.items
|
||||
|
||||
// filter out list items that are delayed
|
||||
if (!this.show_delay && shopping_list) {
|
||||
shopping_list = shopping_list.filter((x) => !x.delay_until || !Date.parse(x?.delay_until) > new Date(Date.now()))
|
||||
}
|
||||
|
||||
// if a supermarket is selected and filtered to only supermarket categories filter out everything else
|
||||
if (this.selected_supermarket && this.supermarket_categories_only) {
|
||||
let shopping_categories = this.supermarkets // category IDs configured on supermarket
|
||||
.map((x) => x.category_to_supermarket)
|
||||
.flat()
|
||||
.map((x) => x.category.id)
|
||||
shopping_list = shopping_list.filter((x) => shopping_categories.includes(x?.food?.supermarket_category?.id))
|
||||
// if showing undefined is off, filter undefined
|
||||
} else if (!this.show_undefined_categories) {
|
||||
shopping_list = shopping_list.filter((x) => x?.food?.supermarket_category)
|
||||
}
|
||||
|
||||
let groups = { false: {}, true: {} } // force unchecked to always be first
|
||||
if (this.selected_supermarket) {
|
||||
let super_cats = this.supermarkets
|
||||
.filter((x) => x.id === this.selected_supermarket)
|
||||
.map((x) => x.category_to_supermarket)
|
||||
.flat()
|
||||
.map((x) => x.category.name)
|
||||
new Set([...super_cats, ...this.shopping_categories.map((x) => x.name)]).forEach((cat) => {
|
||||
groups["false"][cat.name] = {}
|
||||
groups["true"][cat.name] = {}
|
||||
})
|
||||
} else {
|
||||
this.shopping_categories.forEach((cat) => {
|
||||
groups.false[cat.name] = {}
|
||||
groups.true[cat.name] = {}
|
||||
})
|
||||
}
|
||||
|
||||
shopping_list.forEach((item) => {
|
||||
let key = getKey(item, this.group_by, this.$t("Undefined"))
|
||||
// first level of dict is done/not done
|
||||
if (!groups[item.checked]) groups[item.checked] = {}
|
||||
|
||||
// second level of dict is this.group_by selection
|
||||
if (!groups[item.checked][key]) groups[item.checked][key] = {}
|
||||
|
||||
// third level of dict is the food
|
||||
if (groups[item.checked][key][item.food.name]) {
|
||||
groups[item.checked][key][item.food.name].push(item)
|
||||
} else {
|
||||
groups[item.checked][key][item.food.name] = [item]
|
||||
}
|
||||
})
|
||||
return groups
|
||||
},
|
||||
defaultDelay() {
|
||||
return getUserPreference("default_delay") || 2
|
||||
},
|
||||
itemsDelayed() {
|
||||
return this.items.filter((x) => !x.delay_until || !Date.parse(x?.delay_until) > new Date(Date.now())).length < this.items.length
|
||||
},
|
||||
filterApplied() {
|
||||
return (this.itemsDelayed && !this.show_delay) || !this.show_undefined_categories || (this.supermarket_categories_only && this.selected_supermarket)
|
||||
},
|
||||
Recipes() {
|
||||
return [...new Map(this.items.filter((x) => x.list_recipe).map((item) => [item["list_recipe"], item])).values()]
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selected_supermarket(newVal, oldVal) {
|
||||
this.supermarket_categories_only = this.settings.filter_to_supermarket
|
||||
},
|
||||
"settings.filter_to_supermarket": function(newVal, oldVal) {
|
||||
this.supermarket_categories_only = this.settings.filter_to_supermarket
|
||||
},
|
||||
"settings.shopping_auto_sync": function(newVal, oldVal) {
|
||||
clearInterval(this.autosync_id)
|
||||
this.autosync_id = undefined
|
||||
if (!newVal) {
|
||||
window.removeEventListener("online", this.updateOnlineStatus)
|
||||
window.removeEventListener("offline", this.updateOnlineStatus)
|
||||
return
|
||||
} else if (oldVal === 0 && newVal > 0) {
|
||||
window.addEventListener("online", this.updateOnlineStatus)
|
||||
window.addEventListener("offline", this.updateOnlineStatus)
|
||||
}
|
||||
this.autosync_id = setInterval(() => {
|
||||
if (this.online && !this.auto_sync_running) {
|
||||
this.auto_sync_running = true
|
||||
this.getShoppingList(true)
|
||||
}
|
||||
}, this.settings.shopping_auto_sync * 1000)
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getShoppingList()
|
||||
this.getSupermarkets()
|
||||
this.getShoppingCategories()
|
||||
|
||||
this.settings = getUserPreference()
|
||||
this.delay = this.settings.default_delay || 4
|
||||
this.supermarket_categories_only = this.settings.filter_to_supermarket
|
||||
if (this.settings.shopping_auto_sync) {
|
||||
window.addEventListener("online", this.updateOnlineStatus)
|
||||
window.addEventListener("offline", this.updateOnlineStatus)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
addItem() {
|
||||
let api = new ApiApiFactory()
|
||||
api.createShoppingListEntry(this.new_item)
|
||||
.then((results) => {
|
||||
if (results?.data) {
|
||||
this.items.push(results.data)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
} else {
|
||||
console.log("no data returned")
|
||||
}
|
||||
this.new_item = { amount: 1 }
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
},
|
||||
categoryName: function(value) {
|
||||
return this.shopping_categories.filter((x) => x.id == value)[0]?.name ?? ""
|
||||
},
|
||||
resetFilters: function() {
|
||||
this.selected_supermarket = undefined
|
||||
this.supermarket_categories_only = this.settings.filter_to_supermarket
|
||||
this.show_undefined_categories = true
|
||||
this.group_by = "category"
|
||||
this.show_delay = false
|
||||
},
|
||||
delayThis: function(item) {
|
||||
let entries = []
|
||||
let promises = []
|
||||
if (Array.isArray(item)) {
|
||||
entries = item.map((x) => x.id)
|
||||
} else {
|
||||
entries = [item.id]
|
||||
}
|
||||
let delay_date = new Date(Date.now() + this.delay * (60 * 60 * 1000))
|
||||
|
||||
entries.forEach((entry) => {
|
||||
promises.push(this.saveThis({ id: entry, delay_until: delay_date }, false))
|
||||
})
|
||||
Promise.all(promises).then(() => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
this.delay = this.defaultDelay
|
||||
})
|
||||
},
|
||||
deleteRecipe: function(e, recipe) {
|
||||
let api = new ApiApiFactory()
|
||||
api.destroyShoppingListRecipe(recipe)
|
||||
.then((x) => {
|
||||
this.items = this.items.filter((x) => x.list_recipe !== recipe)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||
})
|
||||
},
|
||||
deleteThis: function(item) {
|
||||
let api = new ApiApiFactory()
|
||||
let entries = []
|
||||
let promises = []
|
||||
if (Array.isArray(item)) {
|
||||
entries = item.map((x) => x.id)
|
||||
} else {
|
||||
entries = [item.id]
|
||||
}
|
||||
|
||||
entries.forEach((x) => {
|
||||
promises.push(
|
||||
api.destroyShoppingListEntry(x).catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
Promise.all(promises).then((result) => {
|
||||
this.items = this.items.filter((x) => !entries.includes(x.id))
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
})
|
||||
},
|
||||
foodName: function(value) {
|
||||
return value?.food?.name ?? value?.[0]?.food?.name ?? ""
|
||||
},
|
||||
getShoppingCategories: function() {
|
||||
let api = new ApiApiFactory()
|
||||
api.listSupermarketCategorys().then((result) => {
|
||||
this.shopping_categories = result.data
|
||||
})
|
||||
},
|
||||
getShoppingList: function(autosync = false) {
|
||||
let params = {}
|
||||
params.supermarket = this.selected_supermarket
|
||||
|
||||
params.options = { query: { recent: 1 } }
|
||||
if (autosync) {
|
||||
params.options.query["autosync"] = 1
|
||||
} else {
|
||||
this.loading = true
|
||||
}
|
||||
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params)
|
||||
.then((results) => {
|
||||
if (!autosync) {
|
||||
if (results.data?.length) {
|
||||
this.items = results.data
|
||||
} else {
|
||||
console.log("no data returned")
|
||||
}
|
||||
this.loading = false
|
||||
} else {
|
||||
this.mergeShoppingList(results.data)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
if (!autosync) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||
}
|
||||
})
|
||||
},
|
||||
getSupermarkets: function() {
|
||||
let api = new ApiApiFactory()
|
||||
api.listSupermarkets().then((result) => {
|
||||
this.supermarkets = result.data
|
||||
})
|
||||
},
|
||||
getThis: function(id) {
|
||||
return this.genericAPI(this.Models.SHOPPING_CATEGORY, this.Actions.FETCH, { id: id })
|
||||
},
|
||||
ignoreThis: function(item) {
|
||||
let food = {
|
||||
id: item?.[0]?.food.id ?? item.food.id,
|
||||
ignore_shopping: true,
|
||||
}
|
||||
this.updateFood(food, "ignore_shopping")
|
||||
},
|
||||
mergeShoppingList: function(data) {
|
||||
this.items.map((x) =>
|
||||
data.map((y) => {
|
||||
if (y.id === x.id) {
|
||||
x.checked = y.checked
|
||||
return x
|
||||
}
|
||||
})
|
||||
)
|
||||
this.auto_sync_running = false
|
||||
},
|
||||
moveEntry: function(e, item) {
|
||||
if (!e) {
|
||||
makeToast(this.$t("Warning"), this.$t("NoCategory"), "warning")
|
||||
}
|
||||
|
||||
// TODO make decision - should inheritance always be turned off when category set manually or give user a choice at front-end or make it a setting?
|
||||
let food = this.items.filter((x) => x.food.id == item?.[0]?.food.id ?? item.food.id)[0].food
|
||||
food.supermarket_category = this.shopping_categories.filter((x) => x?.id === this.shopcat)?.[0]
|
||||
this.updateFood(food, "supermarket_category")
|
||||
this.shopcat = null
|
||||
},
|
||||
onHand: function(item) {
|
||||
let api = new ApiApiFactory()
|
||||
let food = {
|
||||
id: item?.[0]?.food.id ?? item?.food?.id,
|
||||
on_hand: true,
|
||||
}
|
||||
|
||||
this.updateFood(food)
|
||||
.then((result) => {
|
||||
let entries = this.items.filter((x) => x.food.id == food.id).map((x) => x.id)
|
||||
this.items = this.items.filter((x) => x.food.id !== food.id)
|
||||
return entries
|
||||
})
|
||||
.then((entries) => {
|
||||
entries.forEach((x) => {
|
||||
api.destroyShoppingListEntry(x).then((result) => {})
|
||||
})
|
||||
})
|
||||
},
|
||||
openContextMenu(e, value) {
|
||||
this.shopcat = value?.food?.supermarket_category?.id ?? value?.[0]?.food?.supermarket_category?.id ?? undefined
|
||||
this.$refs.menu.open(e, value)
|
||||
},
|
||||
saveSettings: function() {
|
||||
let api = ApiApiFactory()
|
||||
api.partialUpdateUserPreference(this.settings.user, this.settings)
|
||||
.then((result) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
},
|
||||
saveThis: function(thisItem, toast = true) {
|
||||
let api = new ApiApiFactory()
|
||||
if (!thisItem?.id) {
|
||||
// if there is no item id assume it's a new item
|
||||
return api
|
||||
.createShoppingListEntry(thisItem)
|
||||
.then((result) => {
|
||||
if (toast) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
} else {
|
||||
return api
|
||||
.partialUpdateShoppingListEntry(thisItem.id, thisItem)
|
||||
.then((result) => {
|
||||
if (toast) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err, err.response)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
}
|
||||
},
|
||||
sectionID: function(a, b) {
|
||||
return (a + b).replace(/\W/g, "")
|
||||
},
|
||||
updateChecked: function(update) {
|
||||
// when checking a sub item don't refresh the screen until all entries complete but change class to cross out
|
||||
let promises = []
|
||||
update.entries.forEach((x) => {
|
||||
promises.push(this.saveThis({ id: x, checked: update.checked }, false))
|
||||
let item = this.items.filter((entry) => entry.id == x)[0]
|
||||
|
||||
Vue.set(item, "checked", update.checked)
|
||||
if (update.checked) {
|
||||
Vue.set(item, "completed_at", new Date().toISOString())
|
||||
} else {
|
||||
Vue.set(item, "completed_at", undefined)
|
||||
}
|
||||
})
|
||||
Promise.all(promises).catch((err) => {
|
||||
console.log(err, err.response)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
},
|
||||
updateFood: function(food, field) {
|
||||
let api = new ApiApiFactory()
|
||||
let ignore_category
|
||||
if (field) {
|
||||
ignore_category = food.ignore_inherit
|
||||
.map((x) => food.ignore_inherit.fields)
|
||||
.flat()
|
||||
.includes(field)
|
||||
} else {
|
||||
ignore_category = true
|
||||
}
|
||||
|
||||
return api
|
||||
.partialUpdateFood(food.id, food)
|
||||
.then((result) => {
|
||||
if (food.inherit && food.supermarket_category && !ignore_category && food.parent) {
|
||||
makeToast(this.$t("Warning"), this.$t("InheritWarning", { food: food.name }), "warning")
|
||||
} else {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
}
|
||||
if (food?.numchild > 0) {
|
||||
this.getShoppingList() // if food has children, just get the whole list. probably could be more efficient
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err, Object.keys(err))
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
},
|
||||
updateServings(e, plan) {
|
||||
// maybe this needs debounced?
|
||||
let api = new ApiApiFactory()
|
||||
api.partialUpdateShoppingListRecipe(plan, { id: plan, servings: e }).then(() => {
|
||||
this.getShoppingList()
|
||||
})
|
||||
},
|
||||
updateOnlineStatus(e) {
|
||||
const { type } = e
|
||||
this.online = type === "online"
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener("online", this.updateOnlineStatus)
|
||||
window.removeEventListener("offline", this.updateOnlineStatus)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
|
||||
|
||||
<style>
|
||||
.rotate {
|
||||
-moz-transition: all 0.25s linear;
|
||||
-webkit-transition: all 0.25s linear;
|
||||
transition: all 0.25s linear;
|
||||
}
|
||||
.btn[aria-expanded="true"] > .rotate {
|
||||
-moz-transform: rotate(90deg);
|
||||
-webkit-transform: rotate(90deg);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.float-top {
|
||||
padding-bottom: -3em;
|
||||
margin-bottom: -3em;
|
||||
}
|
||||
.float-up {
|
||||
padding-top: -3em;
|
||||
margin-top: -3em;
|
||||
}
|
||||
</style>
|
10
vue/src/apps/ShoppingListView/main.js
Normal file
10
vue/src/apps/ShoppingListView/main.js
Normal file
@ -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")
|
@ -4,16 +4,22 @@
|
||||
:item="item"/>
|
||||
<icon-badge v-if="Icon"
|
||||
:item="item"/>
|
||||
<on-hand-badge v-if="OnHand"
|
||||
:item="item"/>
|
||||
<shopping-badge v-if="Shopping"
|
||||
:item="item"/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LinkedRecipe from "@/components/Badges/LinkedRecipe";
|
||||
import IconBadge from "@/components/Badges/Icon";
|
||||
import OnHandBadge from "@/components/Badges/OnHand";
|
||||
import ShoppingBadge from "@/components/Badges/Shopping";
|
||||
|
||||
export default {
|
||||
name: 'CardBadges',
|
||||
components: {LinkedRecipe, IconBadge},
|
||||
components: {LinkedRecipe, IconBadge, OnHandBadge, ShoppingBadge},
|
||||
props: {
|
||||
item: {type: Object},
|
||||
model: {type: Object}
|
||||
@ -30,6 +36,12 @@ export default {
|
||||
},
|
||||
Icon: function () {
|
||||
return this.model?.badges?.icon ?? false
|
||||
},
|
||||
OnHand: function () {
|
||||
return this.model?.badges?.on_hand ?? false
|
||||
},
|
||||
Shopping: function () {
|
||||
return this.model?.badges?.shopping ?? false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-button v-if="item.icon" class=" btn p-0 border-0" variant="link">
|
||||
<b-button v-if="item.icon" class=" btn px-1 py-0 border-0 text-decoration-none" variant="link">
|
||||
{{item.icon}}
|
||||
</b-button>
|
||||
</span>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-button v-if="item.recipe" v-b-tooltip.hover :title="item.recipe.name"
|
||||
class=" btn fas fa-book-open p-0 border-0" variant="link" :href="item.recipe.url"/>
|
||||
class=" btn text-decoration-none fas fa-book-open px-1 py-0 border-0" variant="link" :href="item.recipe.url"/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
40
vue/src/components/Badges/OnHand.vue
Normal file
40
vue/src/components/Badges/OnHand.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-button class="btn text-decoration-none fas px-1 py-0 border-0" variant="link" v-b-popover.hover.html
|
||||
:title="[onhand ? $t('FoodOnHand', {'food': item.name}) : $t('FoodNotOnHand', {'food': item.name})]"
|
||||
:class="[onhand ? 'text-success fa-clipboard-check' : 'text-muted fa-clipboard' ]"
|
||||
@click="toggleOnHand"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {ApiMixin} from "@/utils/utils";
|
||||
|
||||
export default {
|
||||
name: 'OnHandBadge',
|
||||
props: {
|
||||
item: {type: Object}
|
||||
},
|
||||
mixins: [ ApiMixin ],
|
||||
data() {
|
||||
return {
|
||||
onhand: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.onhand = this.item.on_hand
|
||||
},
|
||||
watch: {
|
||||
},
|
||||
methods: {
|
||||
toggleOnHand() {
|
||||
let params = {'id': this.item.id, 'on_hand': !this.onhand}
|
||||
this.genericAPI(this.Models.FOOD, this.Actions.UPDATE, params).then(() => {
|
||||
this.onhand = !this.onhand
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
94
vue/src/components/Badges/Shopping.vue
Normal file
94
vue/src/components/Badges/Shopping.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-button class="btn text-decoration-none px-1 border-0" variant="link"
|
||||
v-if="ShowBadge"
|
||||
:id="`shopping${item.id}`"
|
||||
@click="addShopping()">
|
||||
<i class="fas"
|
||||
v-b-popover.hover.html
|
||||
:title="[shopping ? $t('RemoveFoodFromShopping', {'food': item.name}) : $t('AddFoodToShopping', {'food': item.name})]"
|
||||
:class="[shopping ? 'text-success fa-shopping-cart' : 'text-muted fa-cart-plus']"
|
||||
/>
|
||||
</b-button>
|
||||
<b-popover :target="`${ShowConfirmation}`" :ref="'shopping'+item.id" triggers="focus" placement="top" >
|
||||
<template #title>{{DeleteConfirmation}}</template>
|
||||
<b-row align-h="end">
|
||||
<b-col cols="auto"><b-button class="btn btn-sm btn-info shadow-none px-1 border-0" @click="cancelDelete()">{{$t("Cancel")}}</b-button>
|
||||
<b-button class="btn btn-sm btn-danger shadow-none px-1" @click="confirmDelete()">{{$t("Confirm")}}</b-button></b-col>
|
||||
</b-row >
|
||||
</b-popover>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
||||
|
||||
export default {
|
||||
name: 'ShoppingBadge',
|
||||
props: {
|
||||
item: {type: Object},
|
||||
override_ignore: {type: Boolean, default: false}
|
||||
},
|
||||
mixins: [ ApiMixin ],
|
||||
data() {
|
||||
return {
|
||||
shopping: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// let random = [true, false,]
|
||||
this.shopping = this.item?.shopping //?? random[Math.floor(Math.random() * random.length)]
|
||||
},
|
||||
computed: {
|
||||
ShowBadge() {
|
||||
if (this.override_ignore) {
|
||||
return true
|
||||
} else {
|
||||
return !this.item.ignore_shopping
|
||||
}
|
||||
},
|
||||
DeleteConfirmation() {
|
||||
return this.$t('DeleteShoppingConfirm',{'food':this.item.name})
|
||||
},
|
||||
ShowConfirmation() {
|
||||
if (this.shopping) {
|
||||
return 'shopping' + this.item.id
|
||||
} else {
|
||||
return 'NoDialog'
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
},
|
||||
methods: {
|
||||
addShopping() {
|
||||
if (this.shopping) {return} // if item already in shopping list, excution handled after confirmation
|
||||
let params = {
|
||||
'id': this.item.id,
|
||||
'amount': 1
|
||||
}
|
||||
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then((result) => {
|
||||
this.shopping = true
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
})
|
||||
},
|
||||
cancelDelete() {
|
||||
this.$refs['shopping' + this.item.id].$emit('close')
|
||||
},
|
||||
confirmDelete() {
|
||||
let params = {
|
||||
'id': this.item.id,
|
||||
'_delete': 'true'
|
||||
}
|
||||
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then(() => {
|
||||
this.shopping = false
|
||||
this.$refs['shopping' + this.item.id].$emit('close')
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
@ -1,28 +1,22 @@
|
||||
<template>
|
||||
<div
|
||||
class="context-menu"
|
||||
ref="popper"
|
||||
v-show="isVisible"
|
||||
tabindex="-1"
|
||||
v-click-outside="close"
|
||||
@contextmenu.capture.prevent>
|
||||
<div class="context-menu" ref="popper" v-show="isVisible" tabindex="-1" v-click-outside="close" @contextmenu.capture.prevent>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<slot :contextData="contextData" name="menu"/>
|
||||
<slot :contextData="contextData" name="menu" />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Popper from 'popper.js';
|
||||
import Popper from "popper.js"
|
||||
|
||||
Popper.Defaults.modifiers.computeStyle.gpuAcceleration = false
|
||||
import ClickOutside from 'vue-click-outside'
|
||||
import ClickOutside from "vue-click-outside"
|
||||
|
||||
export default {
|
||||
name: "ContextMenu.vue",
|
||||
props: {
|
||||
boundariesElement: {
|
||||
type: String,
|
||||
default: 'body',
|
||||
default: "body",
|
||||
},
|
||||
},
|
||||
components: {},
|
||||
@ -30,49 +24,48 @@ export default {
|
||||
return {
|
||||
opened: false,
|
||||
contextData: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
ClickOutside,
|
||||
},
|
||||
computed: {
|
||||
isVisible() {
|
||||
return this.opened;
|
||||
return this.opened
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
open(evt, contextData) {
|
||||
this.opened = true;
|
||||
this.contextData = contextData;
|
||||
this.opened = true
|
||||
this.contextData = contextData
|
||||
|
||||
if (this.popper) {
|
||||
this.popper.destroy();
|
||||
this.popper.destroy()
|
||||
}
|
||||
|
||||
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
|
||||
placement: 'right-start',
|
||||
placement: "right-start",
|
||||
modifiers: {
|
||||
preventOverflow: {
|
||||
boundariesElement: document.querySelector(this.boundariesElement),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
this.popper.scheduleUpdate();
|
||||
});
|
||||
|
||||
this.popper.scheduleUpdate()
|
||||
})
|
||||
},
|
||||
close() {
|
||||
this.opened = false;
|
||||
this.contextData = null;
|
||||
this.opened = false
|
||||
this.contextData = null
|
||||
},
|
||||
referenceObject(evt) {
|
||||
const left = evt.clientX;
|
||||
const top = evt.clientY;
|
||||
const right = left + 1;
|
||||
const bottom = top + 1;
|
||||
const clientWidth = 1;
|
||||
const clientHeight = 1;
|
||||
const left = evt.clientX
|
||||
const top = evt.clientY
|
||||
const right = left + 1
|
||||
const bottom = top + 1
|
||||
const clientWidth = 1
|
||||
const clientHeight = 1
|
||||
|
||||
function getBoundingClientRect() {
|
||||
return {
|
||||
@ -80,24 +73,23 @@ export default {
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const obj = {
|
||||
getBoundingClientRect,
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
};
|
||||
return obj;
|
||||
}
|
||||
return obj
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.popper !== undefined) {
|
||||
this.popper.destroy();
|
||||
this.popper.destroy()
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -123,5 +115,4 @@ export default {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<li @click="$emit('click', $event)" role="presentation">
|
||||
<slot/>
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@ -10,7 +10,4 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
<style scoped></style>
|
||||
|
34
vue/src/components/ContextMenu/ContextMenuSubmenu.vue
Normal file
34
vue/src/components/ContextMenu/ContextMenuSubmenu.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="position: static;" class=" btn-group">
|
||||
<div class="dropdown b-dropdown position-static">
|
||||
<li @click="$refs.submenu.open($event)" role="presentation" class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
|
||||
<slot />
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu ref="submenu">
|
||||
<template #menu="{ contextData }">
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
moveEntry(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-cubes"></i> submenu item</a>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ContextMenu from "@/components/ContextMenu/ContextMenu"
|
||||
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
|
||||
export default {
|
||||
name: "ContextSubmenu.vue",
|
||||
components: { ContextMenu, ContextMenuItem },
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
@ -26,7 +26,11 @@
|
||||
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t('Add_to_Shopping') }}
|
||||
</a>
|
||||
|
||||
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
|
||||
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#">
|
||||
<i class="fas fa-shopping-cart fa-fw"></i> New {{ $t('Add_to_Shopping') }}
|
||||
</a>
|
||||
|
||||
<a class="dropdown-item" @click="createMealPlan" href="#"><i
|
||||
class="fas fa-calendar fa-fw"></i> {{ $t('Add_to_Plan') }}
|
||||
</a>
|
||||
|
||||
@ -76,6 +80,7 @@
|
||||
<meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="[recipe]"
|
||||
:entry-editing_initial_meal_type="[]" @save-entry="saveMealPlan"
|
||||
:modal_id="`modal-meal-plan_${modal_id}`" :allow_delete="false" :modal_title="$t('Create_Meal_Plan_Entry')"></meal-plan-edit-modal>
|
||||
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
@ -92,13 +92,13 @@
|
||||
<i class="fas fa-times fa-fw"></i> <b>{{$t('Cancel')}}</b>
|
||||
</b-list-group-item>
|
||||
<!-- TODO add to shopping list -->
|
||||
<!-- TODO add to and/or manage pantry -->
|
||||
<!-- TODO toggle onhand -->
|
||||
</b-list-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import GenericContextMenu from "@/components/GenericContextMenu";
|
||||
import GenericContextMenu from "@/components/ContextMenu/GenericContextMenu";
|
||||
import Badges from "@/components/Badges";
|
||||
import GenericPill from "@/components/GenericPill";
|
||||
import GenericOrderedPill from "@/components/GenericOrderedPill";
|
||||
|
@ -1,79 +1,208 @@
|
||||
<template>
|
||||
|
||||
<tr @click="$emit('checked-state-changed', ingredient)">
|
||||
<tr>
|
||||
<template v-if="ingredient.is_header">
|
||||
<td colspan="5">
|
||||
<td colspan="5" @click="done">
|
||||
<b>{{ ingredient.note }}</b>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<td class="d-print-non" v-if="detailed">
|
||||
<td class="d-print-non" v-if="detailed && !add_shopping_mode" @click="done">
|
||||
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
|
||||
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
|
||||
</td>
|
||||
<td>
|
||||
<td class="text-nowrap" @click="done">
|
||||
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<td @click="done">
|
||||
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<td @click="done">
|
||||
<template v-if="ingredient.food !== null">
|
||||
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null"
|
||||
target="_blank" rel="noopener noreferrer">{{ ingredient.food.name }}</a>
|
||||
<!-- <i
|
||||
v-if="show_shopping && !add_shopping_mode"
|
||||
class="far fa-edit fa-sm px-1"
|
||||
@click="editFood()"
|
||||
></i> -->
|
||||
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null" target="_blank" rel="noopener noreferrer">{{
|
||||
ingredient.food.name
|
||||
}}</a>
|
||||
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="detailed">
|
||||
<td v-if="detailed && !show_shopping">
|
||||
<div v-if="ingredient.note">
|
||||
<span v-b-popover.hover="ingredient.note"
|
||||
class="d-print-none touchable"> <i class="far fa-comment"></i>
|
||||
<span v-b-popover.hover="ingredient.note" class="d-print-none touchable">
|
||||
<i class="far fa-comment"></i>
|
||||
</span>
|
||||
<!-- v-if="ingredient.note.length > 15" -->
|
||||
<!-- <span v-else>-->
|
||||
<!-- {{ ingredient.note }}-->
|
||||
<!-- </span>-->
|
||||
<!-- <span v-else>-->
|
||||
<!-- {{ ingredient.note }}-->
|
||||
<!-- </span>-->
|
||||
|
||||
<div class="d-none d-print-block">
|
||||
<i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}
|
||||
<div class="d-none d-print-block"><i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td v-else-if="show_shopping" class="text-right text-nowrap">
|
||||
<!-- in shopping mode and ingredient is not ignored -->
|
||||
<div v-if="!ingredient.food.ignore_shopping">
|
||||
<b-button
|
||||
class="btn text-decoration-none fas fa-shopping-cart px-2 user-select-none"
|
||||
variant="link"
|
||||
v-b-popover.hover.click.blur.html.top="{ title: ShoppingPopover, variant: 'outline-dark' }"
|
||||
:class="{
|
||||
'text-success': shopping_status === true,
|
||||
'text-muted': shopping_status === false,
|
||||
'text-warning': shopping_status === null,
|
||||
}"
|
||||
/>
|
||||
<span class="px-2">
|
||||
<input type="checkbox" class="align-middle" v-model="shop" @change="changeShopping" />
|
||||
</span>
|
||||
<on-hand-badge :item="ingredient.food" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- or in shopping mode and food is ignored: Shopping Badge bypasses linking ingredient to Recipe which would get ignored -->
|
||||
<shopping-badge :item="ingredient.food" :override_ignore="true" class="px-1" />
|
||||
<span class="px-2">
|
||||
<input type="checkbox" class="align-middle" disabled v-b-popover.hover.click.blur :title="$t('IgnoredFood', { food: ingredient.food.name })" />
|
||||
</span>
|
||||
<on-hand-badge :item="ingredient.food" />
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {calculateAmount, ResolveUrlMixin} from "@/utils/utils";
|
||||
import { calculateAmount, ResolveUrlMixin, ApiMixin } from "@/utils/utils"
|
||||
import OnHandBadge from "@/components/Badges/OnHand"
|
||||
import ShoppingBadge from "@/components/Badges/Shopping"
|
||||
|
||||
export default {
|
||||
name: 'IngredientComponent',
|
||||
name: "Ingredient",
|
||||
components: { OnHandBadge, ShoppingBadge },
|
||||
props: {
|
||||
ingredient: Object,
|
||||
ingredient_factor: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
ingredient_factor: { type: Number, default: 1 },
|
||||
detailed: { type: Boolean, default: true },
|
||||
recipe_list: { type: Number }, // ShoppingListRecipe ID, to filter ShoppingStatus
|
||||
show_shopping: { type: Boolean, default: false },
|
||||
add_shopping_mode: { type: Boolean, default: false },
|
||||
shopping_list: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
detailed: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}, // list of unchecked ingredients in shopping list
|
||||
},
|
||||
mixins: [
|
||||
ResolveUrlMixin
|
||||
],
|
||||
mixins: [ResolveUrlMixin, ApiMixin],
|
||||
data() {
|
||||
return {
|
||||
checked: false
|
||||
checked: false,
|
||||
shopping_status: null,
|
||||
shopping_items: [],
|
||||
shop: false,
|
||||
dirty: undefined,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
ShoppingListAndFilter: {
|
||||
immediate: true,
|
||||
handler(newVal, oldVal) {
|
||||
let filtered_list = this.shopping_list
|
||||
// if a recipe list is provided, filter the shopping list
|
||||
if (this.recipe_list) {
|
||||
filtered_list = filtered_list.filter((x) => x.list_recipe == this.recipe_list)
|
||||
}
|
||||
// how many ShoppingListRecipes are there for this recipe?
|
||||
let count_shopping_recipes = [...new Set(filtered_list.map((x) => x.list_recipe))].length
|
||||
let count_shopping_ingredient = filtered_list.filter((x) => x.ingredient == this.ingredient.id).length
|
||||
|
||||
if (count_shopping_recipes > 1) {
|
||||
this.shop = false // don't check any boxes until user selects a shopping list to edit
|
||||
if (count_shopping_ingredient >= 1) {
|
||||
this.shopping_status = true
|
||||
} else if (this.ingredient.food.shopping) {
|
||||
this.shopping_status = null // food is in the shopping list, just not for this ingredient/recipe
|
||||
} else {
|
||||
this.shopping_status = false // food is not in any shopping list
|
||||
}
|
||||
} else {
|
||||
// mark checked if the food is in the shopping list for this ingredient/recipe
|
||||
if (count_shopping_ingredient >= 1) {
|
||||
// ingredient is in this shopping list
|
||||
this.shop = true
|
||||
this.shopping_status = true
|
||||
} else if (count_shopping_ingredient == 0 && this.ingredient.food.shopping) {
|
||||
// food is in the shopping list, just not for this ingredient/recipe
|
||||
this.shop = false
|
||||
this.shopping_status = null
|
||||
} else {
|
||||
// the food is not in any shopping list
|
||||
this.shop = false
|
||||
this.shopping_status = false
|
||||
}
|
||||
}
|
||||
// if we are in add shopping mode start with all checks marked
|
||||
if (this.add_shopping_mode) {
|
||||
this.shop = !this.ingredient.food.on_hand && !this.ingredient.food.ignore_shopping && !this.ingredient.food.recipe
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {},
|
||||
computed: {
|
||||
ShoppingListAndFilter() {
|
||||
// hack to watch the shopping list and the recipe list at the same time
|
||||
return this.shopping_list.map((x) => x.id).join(this.recipe_list)
|
||||
},
|
||||
ShoppingPopover() {
|
||||
if (this.shopping_status == false) {
|
||||
return this.$t("NotInShopping", { food: this.ingredient.food.name })
|
||||
} else {
|
||||
let list = this.shopping_list.filter((x) => x.food.id == this.ingredient.food.id)
|
||||
let category = this.$t("Category") + ": " + this.ingredient?.food?.supermarket_category?.name ?? this.$t("Undefined")
|
||||
let popover = []
|
||||
|
||||
list.forEach((x) => {
|
||||
popover.push(
|
||||
[
|
||||
"<tr style='border-bottom: 1px solid #ccc'>",
|
||||
"<td style='padding: 3px;'><em>",
|
||||
x?.recipe_mealplan?.name ?? "",
|
||||
"</em></td>",
|
||||
"<td style='padding: 3px;'>",
|
||||
x?.amount ?? "",
|
||||
"</td>",
|
||||
"<td style='padding: 3px;'>",
|
||||
x?.unit?.name ?? "" + "</td>",
|
||||
"<td style='padding: 3px;'>",
|
||||
x?.food?.name ?? "",
|
||||
"</td></tr>",
|
||||
].join("")
|
||||
)
|
||||
})
|
||||
return "<table class='table-small'><th colspan='4'>" + category + "</th>" + popover.join("") + "</table>"
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
calculateAmount: function (x) {
|
||||
calculateAmount: function(x) {
|
||||
return calculateAmount(x, this.ingredient_factor)
|
||||
}
|
||||
}
|
||||
},
|
||||
// sends parent recipe ingredient to notify complete has been toggled
|
||||
done: function() {
|
||||
this.$emit("checked-state-changed", this.ingredient)
|
||||
},
|
||||
// sends true/false to parent to save all ingredient shopping updates as a batch
|
||||
changeShopping: function() {
|
||||
this.$emit("add-to-shopping", { item: this.ingredient, add: this.shop })
|
||||
},
|
||||
editFood: function() {
|
||||
console.log("edit the food")
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
187
vue/src/components/IngredientsCard.vue
Normal file
187
vue/src/components/IngredientsCard.vue
Normal file
@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div :class="{ 'card border-primary no-border': header }">
|
||||
<div :class="{ 'card-body': header }">
|
||||
<div class="row" v-if="header">
|
||||
<div class="col col-md-8">
|
||||
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h4>
|
||||
</div>
|
||||
<div class="col col-md-4 text-right" v-if="header">
|
||||
<h4>
|
||||
<i v-if="show_shopping && ShoppingRecipes.length > 0" class="fas fa-trash text-danger px-2" @click="saveShopping(true)"></i>
|
||||
<i v-if="show_shopping" class="fas fa-save text-success px-2" @click="saveShopping()"></i>
|
||||
<i class="fas fa-shopping-cart px-2" @click="getShopping()"></i>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row text-right" v-if="ShoppingRecipes.length > 1">
|
||||
<div class="col col-md-6 offset-md-6 text-right">
|
||||
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes" size="sm"></b-form-select>
|
||||
</div>
|
||||
</div>
|
||||
<br v-if="header" />
|
||||
<div class="row no-gutter">
|
||||
<div class="col-md-12">
|
||||
<table class="table table-sm">
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="s in steps">
|
||||
<template v-for="i in s.ingredients">
|
||||
<ingredient
|
||||
:ingredient="i"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:key="i.id"
|
||||
:show_shopping="show_shopping"
|
||||
:shopping_list="shopping_list"
|
||||
:add_shopping_mode="add_shopping_mode"
|
||||
:detailed="detailed"
|
||||
:recipe_list="selected_shoppingrecipe"
|
||||
@checked-state-changed="$emit('checked-state-changed', $event)"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import Ingredient from "@/components/Ingredient"
|
||||
import { ApiMixin, StandardToasts } from "@/utils/utils"
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "IngredientCard",
|
||||
mixins: [ApiMixin],
|
||||
components: { Ingredient },
|
||||
props: {
|
||||
steps: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
recipe: { type: Number },
|
||||
ingredient_factor: { type: Number, default: 1 },
|
||||
servings: { type: Number, default: 1 },
|
||||
detailed: { type: Boolean, default: true },
|
||||
header: { type: Boolean, default: false },
|
||||
add_shopping_mode: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show_shopping: false,
|
||||
shopping_list: [],
|
||||
update_shopping: [],
|
||||
selected_shoppingrecipe: undefined,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
ShoppingRecipes() {
|
||||
// returns open shopping lists associated with this recipe
|
||||
let recipe_in_list = this.shopping_list
|
||||
.map((x) => {
|
||||
return { value: x?.list_recipe, text: x?.recipe_mealplan?.name, recipe: x?.recipe_mealplan?.recipe ?? 0, servings: x?.recipe_mealplan?.servings }
|
||||
})
|
||||
.filter((x) => x?.recipe == this.recipe)
|
||||
return [...new Map(recipe_in_list.map((x) => [x["value"], x])).values()] // filter to unique lists
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
ShoppingRecipes: function(newVal, oldVal) {
|
||||
if (newVal.length === 0 || this.add_shopping_mode) {
|
||||
this.selected_shoppingrecipe = undefined
|
||||
} else if (newVal.length === 1) {
|
||||
this.selected_shoppingrecipe = newVal[0].value
|
||||
}
|
||||
},
|
||||
selected_shoppingrecipe: function(newVal, oldVal) {
|
||||
this.update_shopping = this.shopping_list.filter((x) => x.list_recipe === newVal).map((x) => x.ingredient)
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.add_shopping_mode) {
|
||||
this.show_shopping = true
|
||||
this.getShopping(false)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getShopping: function(toggle_shopping = true) {
|
||||
if (toggle_shopping) {
|
||||
this.show_shopping = !this.show_shopping
|
||||
}
|
||||
|
||||
if (this.show_shopping) {
|
||||
let ingredient_list = this.steps
|
||||
.map((x) => x.ingredients)
|
||||
.flat()
|
||||
.map((x) => x.food.id)
|
||||
|
||||
let params = {
|
||||
id: ingredient_list,
|
||||
checked: "false",
|
||||
}
|
||||
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params).then((result) => {
|
||||
this.shopping_list = result.data
|
||||
})
|
||||
}
|
||||
},
|
||||
saveShopping: function(del_shopping = false) {
|
||||
let servings = this.servings
|
||||
if (del_shopping) {
|
||||
servings = 0
|
||||
}
|
||||
let params = {
|
||||
id: this.recipe,
|
||||
list_recipe: this.selected_shoppingrecipe,
|
||||
ingredients: this.update_shopping,
|
||||
servings: servings,
|
||||
}
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.SHOPPING, params)
|
||||
.then(() => {
|
||||
if (del_shopping) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
} else if (this.selected_shoppingrecipe) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
} else {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
if (!this.add_shopping_mode) {
|
||||
return this.getShopping(false)
|
||||
} else {
|
||||
this.$emit("shopping-added")
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (del_shopping) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||
} else if (this.selected_shoppingrecipe) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
} else {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
}
|
||||
this.$emit("shopping-failed")
|
||||
})
|
||||
},
|
||||
addShopping: function(e) {
|
||||
// ALERT: this will all break if ingredients are re-used between recipes
|
||||
if (e.add) {
|
||||
this.update_shopping.push(e.item.id)
|
||||
} else {
|
||||
this.update_shopping = this.update_shopping.filter((x) => x !== e.item.id)
|
||||
}
|
||||
if (this.add_shopping_mode) {
|
||||
this.$emit("add-to-shopping", e)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
@ -1,216 +0,0 @@
|
||||
<template>
|
||||
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="" @show="showModal">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="row">
|
||||
<div class="col-6 col-lg-9">
|
||||
<b-input-group>
|
||||
<b-form-input id="TitleInput" v-model="entryEditing.title"
|
||||
:placeholder="entryEditing.title_placeholder"
|
||||
@change="missing_recipe = false"></b-form-input>
|
||||
<b-input-group-append class="d-none d-lg-block">
|
||||
<b-button variant="primary" @click="entryEditing.title = ''"><i class="fa fa-eraser"></i></b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
<span class="text-danger" v-if="missing_recipe">{{ $t('Title_or_Recipe_Required') }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{ $t("Title") }}</small>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date">
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12 col-lg-6 col-xl-6">
|
||||
<b-form-group>
|
||||
<generic-multiselect
|
||||
@change="selectRecipe"
|
||||
:initial_selection="entryEditing_initial_recipe"
|
||||
:label="'name'"
|
||||
:model="Models.RECIPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Recipe')" :limit="10"
|
||||
:multiple="false"></generic-multiselect>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
|
||||
</b-form-group>
|
||||
<b-form-group class="mt-3">
|
||||
<generic-multiselect required
|
||||
@change="selectMealType"
|
||||
:label="'name'"
|
||||
:model="Models.MEAL_TYPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Meal_Type')" :limit="10"
|
||||
:multiple="false"
|
||||
:initial_selection="entryEditing_initial_meal_type"
|
||||
:allow_create="true"
|
||||
:create_placeholder="$t('Create_New_Meal_Type')"
|
||||
@new="createMealType"
|
||||
></generic-multiselect>
|
||||
<span class="text-danger" v-if="missing_meal_type">{{ $t('Meal_Type_Required') }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{ $t("Meal_Type") }}</small>
|
||||
</b-form-group>
|
||||
<b-form-group
|
||||
label-for="NoteInput"
|
||||
:description="$t('Note')" class="mt-3">
|
||||
<textarea class="form-control" id="NoteInput" v-model="entryEditing.note"
|
||||
:placeholder="$t('Note')"></textarea>
|
||||
</b-form-group>
|
||||
<b-input-group>
|
||||
<b-form-input id="ServingsInput" v-model="entryEditing.servings"
|
||||
:placeholder="$t('Servings')"></b-form-input>
|
||||
</b-input-group>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
||||
<b-form-group class="mt-3">
|
||||
<generic-multiselect required
|
||||
@change="entryEditing.shared = $event.val" parent_variable="entryEditing.shared"
|
||||
:label="'username'"
|
||||
:model="Models.USER_NAME"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Share')" :limit="10"
|
||||
:multiple="true"
|
||||
:initial_selection="entryEditing.shared"
|
||||
></generic-multiselect>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
|
||||
</b-form-group>
|
||||
</div>
|
||||
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
||||
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3 mb-3">
|
||||
<div class="col-12">
|
||||
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete">{{ $t('Delete') }}
|
||||
</b-button>
|
||||
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t('Save') }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import {BootstrapVue} from "bootstrap-vue";
|
||||
import GenericMultiselect from "./GenericMultiselect";
|
||||
import {ApiMixin} from "../utils/utils";
|
||||
|
||||
const {ApiApiFactory} = require("@/utils/openapi/api");
|
||||
|
||||
const {StandardToasts} = require("@/utils/utils");
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "MealPlanEditModal",
|
||||
props: {
|
||||
entry: Object,
|
||||
entryEditing_initial_recipe: Array,
|
||||
entryEditing_initial_meal_type: Array,
|
||||
modal_title: String,
|
||||
modal_id: {
|
||||
type: String,
|
||||
default: "edit-modal"
|
||||
},
|
||||
allow_delete: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
mixins: [ApiMixin],
|
||||
components: {
|
||||
GenericMultiselect,
|
||||
RecipeCard: () => import('@/components/RecipeCard.vue')
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
entryEditing: {},
|
||||
missing_recipe: false,
|
||||
missing_meal_type: false,
|
||||
default_plan_share: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
entry: {
|
||||
handler() {
|
||||
this.entryEditing = Object.assign({}, this.entry)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showModal() {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.listUserPreferences().then(result => {
|
||||
if (this.entry.id === -1) {
|
||||
this.entryEditing.shared = result.data[0].plan_share
|
||||
}
|
||||
})
|
||||
},
|
||||
editEntry() {
|
||||
this.missing_meal_type = false
|
||||
this.missing_recipe = false
|
||||
let cancel = false
|
||||
if (this.entryEditing.meal_type == null) {
|
||||
this.missing_meal_type = true
|
||||
cancel = true
|
||||
}
|
||||
if (this.entryEditing.recipe == null && this.entryEditing.title === '') {
|
||||
this.missing_recipe = true
|
||||
cancel = true
|
||||
}
|
||||
if (!cancel) {
|
||||
this.$bvModal.hide(`edit-modal`);
|
||||
this.$emit('save-entry', this.entryEditing)
|
||||
}
|
||||
},
|
||||
deleteEntry() {
|
||||
this.$bvModal.hide(`edit-modal`);
|
||||
this.$emit('delete-entry', this.entryEditing)
|
||||
},
|
||||
selectMealType(event) {
|
||||
this.missing_meal_type = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.meal_type = event.val;
|
||||
} else {
|
||||
this.entryEditing.meal_type = null;
|
||||
}
|
||||
},
|
||||
selectShared(event) {
|
||||
if (event.val != null) {
|
||||
this.entryEditing.shared = event.val;
|
||||
} else {
|
||||
this.entryEditing.meal_type = null;
|
||||
}
|
||||
},
|
||||
createMealType(event) {
|
||||
if (event != "") {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.createMealType({name: event}).then(e => {
|
||||
this.$emit('reload-meal-types')
|
||||
}).catch(error => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
}
|
||||
},
|
||||
selectRecipe(event) {
|
||||
this.missing_recipe = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.recipe = event.val;
|
||||
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
||||
this.entryEditing.servings = this.entryEditing.recipe.servings
|
||||
} else {
|
||||
this.entryEditing.recipe = null;
|
||||
this.entryEditing.title_placeholder = ""
|
||||
this.entryEditing.servings = 1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@ -28,7 +28,7 @@
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import { getForm } from "@/utils/utils"
|
||||
import { getForm, formFunctions } from "@/utils/utils"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
@ -84,6 +84,10 @@ export default {
|
||||
show: function () {
|
||||
if (this.show) {
|
||||
this.form = getForm(this.model, this.action, this.item1, this.item2)
|
||||
// TODO: I don't know how to generalize this, but Food needs default values to drive inheritance
|
||||
if (this.form?.form_function) {
|
||||
this.form = formFunctions[this.form.form_function](this.form)
|
||||
}
|
||||
this.dirty = true
|
||||
this.$bvModal.show("modal_" + this.id)
|
||||
} else {
|
||||
|
219
vue/src/components/Modals/MealPlanEditModal.vue
Normal file
219
vue/src/components/Modals/MealPlanEditModal.vue
Normal file
@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="row">
|
||||
<div class="col-6 col-lg-9">
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
id="TitleInput"
|
||||
v-model="entryEditing.title"
|
||||
:placeholder="entryEditing.title_placeholder"
|
||||
@change="missing_recipe = false"
|
||||
></b-form-input>
|
||||
<b-input-group-append class="d-none d-lg-block">
|
||||
<b-button variant="primary" @click="entryEditing.title = ''"
|
||||
><i class="fa fa-eraser"></i
|
||||
></b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
<span class="text-danger" v-if="missing_recipe">{{ $t("Title_or_Recipe_Required") }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{
|
||||
$t("Title")
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date" />
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12 col-lg-6 col-xl-6">
|
||||
<b-form-group>
|
||||
<generic-multiselect
|
||||
@change="selectRecipe"
|
||||
:initial_selection="entryEditing_initial_recipe"
|
||||
:label="'name'"
|
||||
:model="Models.RECIPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Recipe')"
|
||||
:limit="10"
|
||||
:multiple="false"
|
||||
></generic-multiselect>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
|
||||
</b-form-group>
|
||||
<b-form-group class="mt-3">
|
||||
<generic-multiselect
|
||||
required
|
||||
@change="selectMealType"
|
||||
:label="'name'"
|
||||
:model="Models.MEAL_TYPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Meal_Type')"
|
||||
:limit="10"
|
||||
:multiple="false"
|
||||
:initial_selection="entryEditing_initial_meal_type"
|
||||
:allow_create="true"
|
||||
:create_placeholder="$t('Create_New_Meal_Type')"
|
||||
@new="createMealType"
|
||||
></generic-multiselect>
|
||||
<span class="text-danger" v-if="missing_meal_type">{{ $t("Meal_Type_Required") }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{
|
||||
$t("Meal_Type")
|
||||
}}</small>
|
||||
</b-form-group>
|
||||
<b-form-group label-for="NoteInput" :description="$t('Note')" class="mt-3">
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="NoteInput"
|
||||
v-model="entryEditing.note"
|
||||
:placeholder="$t('Note')"
|
||||
></textarea>
|
||||
</b-form-group>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
id="ServingsInput"
|
||||
v-model="entryEditing.servings"
|
||||
:placeholder="$t('Servings')"
|
||||
></b-form-input>
|
||||
</b-input-group>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
||||
<!-- TODO: hide this checkbox if autoadding menuplans, but allow editing on-hand -->
|
||||
<b-input-group v-if="!autoMealPlan">
|
||||
<b-form-checkbox id="AddToShopping" v-model="entryEditing.addshopping" />
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("AddToShopping") }}</small>
|
||||
</b-input-group>
|
||||
</div>
|
||||
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
||||
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3 mb-3">
|
||||
<div class="col-12">
|
||||
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete"
|
||||
>{{ $t("Delete") }}
|
||||
</b-button>
|
||||
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t("Save") }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||
import { ApiMixin, getUserPreference } from "@/utils/utils"
|
||||
|
||||
const { ApiApiFactory } = require("@/utils/openapi/api")
|
||||
|
||||
const { StandardToasts } = require("@/utils/utils")
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "MealPlanEditModal",
|
||||
props: {
|
||||
entry: Object,
|
||||
entryEditing_initial_recipe: Array,
|
||||
entryEditing_initial_meal_type: Array,
|
||||
modal_title: String,
|
||||
modal_id: {
|
||||
type: String,
|
||||
default: "edit-modal",
|
||||
},
|
||||
allow_delete: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
mixins: [ApiMixin],
|
||||
components: {
|
||||
GenericMultiselect,
|
||||
RecipeCard: () => import("@/components/RecipeCard.vue"),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
entryEditing: {},
|
||||
missing_recipe: false,
|
||||
missing_meal_type: false,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
entry: {
|
||||
handler() {
|
||||
this.entryEditing = Object.assign({}, this.entry)
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
mounted: function() {},
|
||||
computed: {
|
||||
autoMealPlan: function() {
|
||||
return getUserPreference("mealplan_autoadd_shopping")
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
editEntry() {
|
||||
this.missing_meal_type = false
|
||||
this.missing_recipe = false
|
||||
let cancel = false
|
||||
if (this.entryEditing.meal_type == null) {
|
||||
this.missing_meal_type = true
|
||||
cancel = true
|
||||
}
|
||||
if (this.entryEditing.recipe == null && this.entryEditing.title === "") {
|
||||
this.missing_recipe = true
|
||||
cancel = true
|
||||
}
|
||||
if (!cancel) {
|
||||
this.$bvModal.hide(`edit-modal`)
|
||||
this.$emit("save-entry", this.entryEditing)
|
||||
}
|
||||
},
|
||||
deleteEntry() {
|
||||
this.$bvModal.hide(`edit-modal`)
|
||||
this.$emit("delete-entry", this.entryEditing)
|
||||
},
|
||||
selectMealType(event) {
|
||||
this.missing_meal_type = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.meal_type = event.val
|
||||
} else {
|
||||
this.entryEditing.meal_type = null
|
||||
}
|
||||
},
|
||||
createMealType(event) {
|
||||
if (event != "") {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient
|
||||
.createMealType({ name: event })
|
||||
.then((e) => {
|
||||
this.$emit("reload-meal-types")
|
||||
})
|
||||
.catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
}
|
||||
},
|
||||
selectRecipe(event) {
|
||||
console.log(event, this.entryEditing)
|
||||
this.missing_recipe = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.recipe = event.val
|
||||
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
||||
this.entryEditing.servings = this.entryEditing.recipe.servings
|
||||
} else {
|
||||
this.entryEditing.recipe = null
|
||||
this.entryEditing.title_placeholder = ""
|
||||
this.entryEditing.servings = 1
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
158
vue/src/components/Modals/ShoppingModal.vue
Normal file
158
vue/src/components/Modals/ShoppingModal.vue
Normal file
@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-modal :id="`shopping_${this.modal_id}`" hide-footer @show="loadRecipe">
|
||||
<template v-slot:modal-title><h4>{{$t('Add_Servings_to_Shopping',{'servings': servings})}}</h4></template>
|
||||
<loading-spinner v-if="loading"></loading-spinner>
|
||||
<div class="accordion" role="tablist" v-if="!loading">
|
||||
<b-card no-body class="mb-1">
|
||||
<b-card-header header-tag="header" class="p-1" role="tab">
|
||||
<b-button block v-b-toggle.accordion-0 class="text-left" variant="outline-info">{{recipe.name}}</b-button>
|
||||
</b-card-header>
|
||||
<b-collapse id="accordion-0" visible accordion="my-accordion" role="tabpanel">
|
||||
<ingredients-card
|
||||
:steps="steps"
|
||||
:recipe="recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:show_shopping="true"
|
||||
:add_shopping_mode="true"
|
||||
:header="false"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
/>
|
||||
</b-collapse>
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="r in related_recipes">
|
||||
<b-card no-body class="mb-1" :key="r.recipe.id">
|
||||
<b-card-header header-tag="header" class="p-1" role="tab">
|
||||
<b-button btn-sm block v-b-toggle="'accordion-' +r.recipe.id" class="text-left" variant="outline-primary">{{r.recipe.name}}</b-button>
|
||||
</b-card-header>
|
||||
<b-collapse :id="'accordion-'+r.recipe.id" accordion="my-accordion" role="tabpanel">
|
||||
<ingredients-card
|
||||
:steps="r.steps"
|
||||
:recipe="r.recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:show_shopping="true"
|
||||
:add_shopping_mode="true"
|
||||
:header="false"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
/>
|
||||
</b-collapse>
|
||||
</b-card>
|
||||
</template>
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
</b-card>
|
||||
|
||||
</div>
|
||||
<div class="row mt-3 mb-3">
|
||||
<div class="col-12 text-right">
|
||||
<b-button class="mx-2" variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t('Cancel') }}
|
||||
</b-button>
|
||||
<b-button class="mx-2" variant="success" @click="saveShopping">{{ $t('Save') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
const {ApiApiFactory} = require("@/utils/openapi/api");
|
||||
import {StandardToasts} from "@/utils/utils";
|
||||
import IngredientsCard from "@/components/IngredientsCard";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
||||
|
||||
export default {
|
||||
name: 'ShoppingModal',
|
||||
components: {IngredientsCard, LoadingSpinner},
|
||||
mixins: [],
|
||||
props: {
|
||||
recipe: {required: true, type: Object},
|
||||
servings: {type: Number},
|
||||
modal_id: {required: true, type: Number},
|
||||
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
steps: [],
|
||||
recipe_servings: 0,
|
||||
add_shopping: [],
|
||||
related_recipes: []
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
return this.servings / this.recipe.servings || this.recipe_servings
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
||||
},
|
||||
methods: {
|
||||
loadRecipe: function() {
|
||||
this.add_shopping = []
|
||||
this.related_recipes = []
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.retrieveRecipe(this.recipe.id).then((result) => {
|
||||
this.steps = result.data.steps
|
||||
// ALERT: this will all break if ingredients are re-used between recipes
|
||||
// ALERT: this also doesn't quite work right if the same recipe appears multiple time in the related recipes
|
||||
this.add_shopping = [...this.add_shopping, ...this.steps.map(x => x.ingredients).flat().map(x => x.id)]
|
||||
this.recipe_servings = result.data?.servings
|
||||
this.loading = false
|
||||
})
|
||||
|
||||
// get a list of related recipes
|
||||
apiClient.relatedRecipe(this.recipe.id).then((result) => {
|
||||
return result.data
|
||||
}).then((related_recipes) => {
|
||||
let promises = []
|
||||
related_recipes.forEach(x => {
|
||||
promises.push(apiClient.listSteps(x.id).then((recipe_steps) => {
|
||||
this.related_recipes.push({
|
||||
'recipe': x,
|
||||
'steps': recipe_steps.data.results.filter(x => x.ingredients.length > 0)
|
||||
})
|
||||
}))
|
||||
})
|
||||
return Promise.all(promises)
|
||||
}).then(() => {
|
||||
this.add_shopping = [
|
||||
...this.add_shopping,
|
||||
...this.related_recipes.map(x => x.steps).flat().map(x => x.ingredients).flat().map(x => x.id)
|
||||
]
|
||||
})
|
||||
},
|
||||
addShopping: function(e) {
|
||||
if (e.add) {
|
||||
this.add_shopping.push(e.item.id)
|
||||
} else {
|
||||
this.add_shopping = this.add_shopping.filter(x => x !== e.item.id)
|
||||
}
|
||||
},
|
||||
saveShopping: function() {
|
||||
// another choice would be to create ShoppingListRecipes for each recipe - this bundles all related recipe under the parent recipe
|
||||
let shopping_recipe = {
|
||||
'id': this.recipe.id,
|
||||
'ingredients': this.add_shopping,
|
||||
'servings': this.servings
|
||||
}
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.shoppingRecipe(this.recipe.id, shopping_recipe).then((result) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}).catch((err) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
this.$bvModal.hide(`shopping_${this.modal_id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,33 +1,25 @@
|
||||
<template>
|
||||
|
||||
|
||||
<b-card no-body v-hover>
|
||||
<b-card no-body v-hover v-if="recipe">
|
||||
<a :href="clickUrl()">
|
||||
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src=recipe_image
|
||||
v-bind:alt="$t('Recipe_Image')"
|
||||
top></b-card-img-lazy>
|
||||
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src="recipe_image" v-bind:alt="$t('Recipe_Image')" top></b-card-img-lazy>
|
||||
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
|
||||
<a>
|
||||
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2"
|
||||
v-if="recipe.working_time !== 0 || recipe.waiting_time !== 0">
|
||||
<b-badge pill variant="light" class="mt-1 font-weight-normal" v-if="recipe.working_time !== 0"><i class="fa fa-clock"></i>
|
||||
{{ recipe.working_time }} {{ $t('min') }}
|
||||
</b-badge>
|
||||
<b-badge pill variant="secondary" class="mt-1 font-weight-normal" v-if="recipe.waiting_time !== 0"><i class="fa fa-pause"></i>
|
||||
{{ recipe.waiting_time }} {{ $t('min') }}
|
||||
</b-badge>
|
||||
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2" v-if="recipe.waiting_time !== 0">
|
||||
<b-badge pill variant="light" class="mt-1 font-weight-normal"><i class="fa fa-clock"></i> {{ recipe.working_time }} {{ $t("min") }} </b-badge>
|
||||
<b-badge pill variant="secondary" class="mt-1 font-weight-normal"><i class="fa fa-pause"></i> {{ recipe.waiting_time }} {{ $t("min") }} </b-badge>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
<b-card-body class="p-4">
|
||||
<h6><a :href="clickUrl()">
|
||||
<h6>
|
||||
<a :href="clickUrl()">
|
||||
<template v-if="recipe !== null">{{ recipe.name }}</template>
|
||||
<template v-else>{{ meal_plan.title }}</template>
|
||||
</a></h6>
|
||||
</a>
|
||||
</h6>
|
||||
|
||||
<b-card-text style="text-overflow: ellipsis;">
|
||||
<template v-if="recipe !== null">
|
||||
@ -42,115 +34,102 @@
|
||||
</template>
|
||||
<p class="mt-1">
|
||||
<last-cooked :recipe="recipe"></last-cooked>
|
||||
<keywords-component :recipe="recipe" style="margin-top: 4px"></keywords-component>
|
||||
<keywords :recipe="recipe" style="margin-top: 4px"></keywords>
|
||||
</p>
|
||||
<transition name="fade" mode="in-out">
|
||||
<div class="row mt-3" v-if="detailed">
|
||||
<div class="col-md-12">
|
||||
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h6>
|
||||
<table class="table table-sm text-wrap">
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="s in recipe.steps">
|
||||
<template v-for="i in s.ingredients">
|
||||
<Ingredient-component :detailed="false" :ingredient="i" :ingredient_factor="1" :key="i.id"></Ingredient-component>
|
||||
</template>
|
||||
</template>
|
||||
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
|
||||
</table>
|
||||
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h6>
|
||||
|
||||
<ingredients-card :steps="recipe.steps" :header="false" :detailed="false" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t('External') }}</b-badge>
|
||||
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t("External") }}</b-badge>
|
||||
<!-- <b-badge pill variant="success"
|
||||
v-if="Date.parse(recipe.created_at) > new Date(Date.now() - (7 * (1000 * 60 * 60 * 24)))">
|
||||
{{ $t('New') }}
|
||||
</b-badge> -->
|
||||
|
||||
</template>
|
||||
<template v-else>{{ meal_plan.note }}</template>
|
||||
</b-card-text>
|
||||
</b-card-body>
|
||||
|
||||
|
||||
<b-card-footer v-if="footer_text !== undefined">
|
||||
<i v-bind:class="footer_icon"></i> {{ footer_text }}
|
||||
</b-card-footer>
|
||||
<b-card-footer v-if="footer_text !== undefined"> <i v-bind:class="footer_icon"></i> {{ footer_text }} </b-card-footer>
|
||||
</b-card>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RecipeContextMenu from "@/components/RecipeContextMenu";
|
||||
import {resolveDjangoUrl, ResolveUrlMixin} from "@/utils/utils";
|
||||
import RecipeRating from "@/components/RecipeRating";
|
||||
import moment from "moment/moment";
|
||||
import Vue from "vue";
|
||||
import LastCooked from "@/components/LastCooked";
|
||||
import KeywordsComponent from "@/components/KeywordsComponent";
|
||||
import IngredientComponent from "@/components/IngredientComponent";
|
||||
import RecipeContextMenu from "@/components/ContextMenu/RecipeContextMenu"
|
||||
import Keywords from "@/components/Keywords"
|
||||
import { resolveDjangoUrl, ResolveUrlMixin } from "@/utils/utils"
|
||||
import RecipeRating from "@/components/RecipeRating"
|
||||
import moment from "moment/moment"
|
||||
import Vue from "vue"
|
||||
import LastCooked from "@/components/LastCooked"
|
||||
import IngredientsCard from "@/components/IngredientsCard"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
export default {
|
||||
name: "RecipeCard",
|
||||
mixins: [
|
||||
ResolveUrlMixin,
|
||||
],
|
||||
components: {LastCooked, RecipeRating, KeywordsComponent, RecipeContextMenu, IngredientComponent},
|
||||
mixins: [ResolveUrlMixin],
|
||||
components: { LastCooked, RecipeRating, Keywords, RecipeContextMenu, IngredientsCard },
|
||||
props: {
|
||||
recipe: Object,
|
||||
meal_plan: Object,
|
||||
footer_text: String,
|
||||
footer_icon: String
|
||||
footer_icon: String,
|
||||
},
|
||||
computed: {
|
||||
detailed: function () {
|
||||
return this.recipe.steps !== undefined;
|
||||
detailed: function() {
|
||||
return this.recipe?.steps !== undefined
|
||||
},
|
||||
text_length: function () {
|
||||
text_length: function() {
|
||||
if (this.detailed) {
|
||||
return 200
|
||||
} else {
|
||||
return 120
|
||||
}
|
||||
},
|
||||
recipe_image: function () {
|
||||
recipe_image: function() {
|
||||
if (this.recipe == null || this.recipe.image === null) {
|
||||
return window.IMAGE_PLACEHOLDER
|
||||
} else {
|
||||
return this.recipe.image
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// TODO: convert this to genericAPI
|
||||
clickUrl: function () {
|
||||
clickUrl: function() {
|
||||
if (this.recipe !== null) {
|
||||
return resolveDjangoUrl('view_recipe', this.recipe.id)
|
||||
return resolveDjangoUrl("view_recipe", this.recipe.id)
|
||||
} else {
|
||||
return resolveDjangoUrl('view_plan_entry', this.meal_plan.id)
|
||||
}
|
||||
return resolveDjangoUrl("view_plan_entry", this.meal_plan.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
directives: {
|
||||
hover: {
|
||||
inserted: function (el) {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
inserted: function(el) {
|
||||
el.addEventListener("mouseenter", () => {
|
||||
el.classList.add("shadow")
|
||||
});
|
||||
el.addEventListener('mouseleave', () => {
|
||||
})
|
||||
el.addEventListener("mouseleave", () => {
|
||||
el.classList.remove("shadow")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity .5s;
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
|
||||
opacity: 0;
|
||||
|
269
vue/src/components/ShoppingLineItem.vue
Normal file
269
vue/src/components/ShoppingLineItem.vue
Normal file
@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<!-- add alert at top if offline -->
|
||||
<!-- get autosync time from preferences and put fetching checked items on timer -->
|
||||
<!-- allow reordering or items -->
|
||||
<div id="shopping_line_item">
|
||||
<div class="col-12">
|
||||
<div class="row">
|
||||
<div class="col col-md-1">
|
||||
<div style="position: static;" class=" btn-group">
|
||||
<div class="dropdown b-dropdown position-static inline-block">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
|
||||
@click.stop="$emit('open-context-menu', $event, entries)"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="checkbox" class="text-right mx-3 mt-2" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<div v-if="Object.entries(formatAmount).length == 1">{{ Object.entries(formatAmount)[0][1] }}   {{ Object.entries(formatAmount)[0][0] }}</div>
|
||||
<div class="small" v-else v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }}   {{ x[0] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-6">
|
||||
{{ formatFood }} <span class="small text-muted">{{ formatHint }}</span>
|
||||
</div>
|
||||
<div class="col col-md-1">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="mr-2" variant="link">
|
||||
<div class="text-nowrap">{{ showDetails ? "Hide" : "Show" }} Details</div>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card no-body" v-if="showDetails">
|
||||
<div v-for="(e, z) in entries" :key="z">
|
||||
<div class="row ml-2 small">
|
||||
<div class="col-md-4 overflow-hidden text-nowrap">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn btn-link btn-sm m-0 p-0"
|
||||
style="text-overflow: ellipsis;"
|
||||
@click.stop="openRecipeCard($event, e)"
|
||||
@mouseover="openRecipeCard($event, e)"
|
||||
>
|
||||
{{ formatOneRecipe(e) }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-4 text-muted">{{ formatOneMealPlan(e) }}</div>
|
||||
<div class="col-md-4 text-muted text-right">{{ formatOneCreatedBy(e) }}</div>
|
||||
</div>
|
||||
<div class="row ml-2 small">
|
||||
<div class="col-md-4 offset-md-8 text-muted text-right">{{ formatOneCompletedAt(e) }}</div>
|
||||
</div>
|
||||
<div class="row ml-2 light">
|
||||
<div class="col-sm-1 text-nowrap">
|
||||
<div style="position: static;" class=" btn-group ">
|
||||
<div class="dropdown b-dropdown position-static inline-block">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
|
||||
@click.stop="$emit('open-context-menu', $event, e)"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="checkbox" class="text-right mx-3 mt-2" :checked="e.checked" @change="updateChecked($event, e)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-1">{{ formatOneAmount(e) }}</div>
|
||||
<div class="col-sm-2">{{ formatOneUnit(e) }}</div>
|
||||
|
||||
<div class="col-sm-3">{{ formatOneFood(e) }}</div>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<div class="small" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="w-75" />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="m-1" />
|
||||
</div>
|
||||
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width:300">
|
||||
<template #menu="{ contextData }" v-if="recipe">
|
||||
<ContextMenuItem><RecipeCard :recipe="contextData" :detail="false"></RecipeCard></ContextMenuItem>
|
||||
<ContextMenuItem @click="$refs.menu.close()">
|
||||
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
|
||||
<template #label>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i> {{ $t("Servings") }}</a>
|
||||
</template>
|
||||
<div @click.prevent.stop>
|
||||
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
|
||||
</div>
|
||||
</b-form-group>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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)
|
||||
|
||||
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 },
|
||||
props: {
|
||||
entries: {
|
||||
type: Array,
|
||||
},
|
||||
groupby: { type: String },
|
||||
},
|
||||
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
|
||||
},
|
||||
formatCategory: function() {
|
||||
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
|
||||
},
|
||||
formatChecked: function() {
|
||||
return this.entries.map((x) => x.checked).every((x) => x === true)
|
||||
},
|
||||
formatHint: function() {
|
||||
if (this.groupby == "recipe") {
|
||||
return this.formatCategory
|
||||
} else {
|
||||
return this.formatRecipe
|
||||
}
|
||||
},
|
||||
formatFood: function() {
|
||||
return this.formatOneFood(this.entries[0])
|
||||
},
|
||||
formatUnit: function() {
|
||||
return this.formatOneUnit(this.entries[0])
|
||||
},
|
||||
formatRecipe: function() {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneMealPlan(this.entries[0]) || ""
|
||||
} 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(" ")
|
||||
}
|
||||
},
|
||||
formatNotes: function() {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneNote(this.entries[0]) || ""
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
watch: {},
|
||||
mounted() {
|
||||
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
|
||||
formatDate: function(datetime) {
|
||||
if (!datetime) {
|
||||
return
|
||||
}
|
||||
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 })
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
|
||||
|
||||
<style>
|
||||
/* table { border-collapse:collapse } /* Ensure no space between cells */
|
||||
/* tr.strikeout td { position:relative } /* Setup a new coordinate system */
|
||||
/* tr.strikeout td:before { /* Create a new element that */
|
||||
/* content: " "; /* …has no text content */
|
||||
/* position: absolute; /* …is absolutely positioned */
|
||||
/* left: 0; top: 50%; width: 100%; /* …with the top across the middle */
|
||||
/* border-bottom: 1px solid #000; /* …and with a border on the top */
|
||||
/* } */
|
||||
</style>
|
@ -38,12 +38,11 @@
|
||||
<div class="col col-md-4"
|
||||
v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
|
||||
<table class="table table-sm">
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="i in step.ingredients">
|
||||
<Ingredient-component v-bind:ingredient="i" :ingredient_factor="ingredient_factor" :key="i.id"
|
||||
@checked-state-changed="$emit('checked-state-changed', i)"></Ingredient-component>
|
||||
</template>
|
||||
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
|
||||
<ingredients-card
|
||||
:steps="[step]"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
@checked-state-changed="$emit('checked-state-changed', $event)"
|
||||
/>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col" :class="{ 'col-md-8': recipe.steps.length > 1, 'col-md-12': recipe.steps.length <= 1,}">
|
||||
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
@ -48,3 +49,7 @@ function handleError(error, message) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Generic class to use OpenAPIs with parameters and provide generic modals
|
||||
* */
|
16
vue/src/utils/apiv2.js
Normal file
16
vue/src/utils/apiv2.js
Normal file
@ -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
|
||||
}
|
||||
}
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
},
|
||||
}
|
||||
|
@ -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")
|
||||
|
Loading…
Reference in New Issue
Block a user