Fix after rebase

This commit is contained in:
smilerz 2021-10-28 07:35:30 -05:00
parent f16e457d14
commit 10a33add75
73 changed files with 5579 additions and 2524 deletions

View File

@ -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)

View File

@ -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
}

View 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")

View File

@ -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()

View File

@ -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.

View 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')

View File

@ -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

View 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),
]

View 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),
]

View 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'),
),
]

View File

@ -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)

View File

@ -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', ]

View File

@ -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)

View File

@ -339,10 +339,10 @@
{% user_prefs request as prefs%}
{{ prefs|json_script:'user_preference' }}
</div>
{% block script %}
{% endblock script %}
<script type="application/javascript">

View File

@ -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 %}

View File

@ -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/>

View File

@ -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 %}

View File

@ -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')

View 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 %}

View File

@ -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 %}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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", [

View File

@ -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

View File

@ -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

View File

@ -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'),

View File

@ -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)

View File

@ -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

View File

@ -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
}
}
)

View File

@ -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

View File

@ -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
View File

@ -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?

View File

@ -0,0 +1,8 @@
.gitignore
.npmignore
api.ts
base.ts
common.ts
configuration.ts
git_push.sh
index.ts

View File

@ -0,0 +1 @@
5.2.1

View File

@ -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>

View File

@ -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')

View File

@ -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)

View File

@ -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>

View File

@ -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)

View File

@ -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) {

View File

@ -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>

View 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>

View 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")

View File

@ -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: {

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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";

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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 {

View 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>

View 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>

View File

@ -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;

View 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] }} &ensp; {{ Object.entries(formatAmount)[0][0] }}</div>
<div class="small" v-else v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }} &ensp; {{ 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>

View File

@ -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,

View File

@ -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",

View File

@ -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
View 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
}
}

View File

@ -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

View File

@ -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
},
}

View File

@ -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")