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): 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) admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)

View File

@ -1,16 +1,14 @@
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError 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.utils.translation import gettext_lazy as _
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
from hcaptcha.fields import hCaptchaField from hcaptcha.fields import hCaptchaField
from .models import (Comment, InviteLink, Keyword, MealPlan, Recipe, from .models import (Comment, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook,
RecipeBook, RecipeBookEntry, Storage, Sync, User, RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference)
UserPreference, MealType, Space,
SearchPreference)
class SelectWidget(widgets.Select): class SelectWidget(widgets.Select):
@ -19,6 +17,7 @@ class SelectWidget(widgets.Select):
class MultiSelectWidget(widgets.SelectMultiple): class MultiSelectWidget(widgets.SelectMultiple):
class Media: class Media:
js = ('custom/js/form_multiselect.js',) js = ('custom/js/form_multiselect.js',)
@ -46,8 +45,7 @@ class UserPreferenceForm(forms.ModelForm):
fields = ( fields = (
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color', 'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
'sticky_navbar', 'default_page', 'show_recent', 'search_style', 'sticky_navbar', 'default_page', 'show_recent', 'search_style',
'plan_share', 'ingredient_decimals', 'shopping_auto_sync', 'plan_share', 'ingredient_decimals', 'comments',
'comments'
) )
labels = { labels = {
@ -75,20 +73,26 @@ class UserPreferenceForm(forms.ModelForm):
# noqa: E501 # noqa: E501
'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501 'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501
'plan_share': _( '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 # noqa: E501
'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501 'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501
'ingredient_decimals': _('Number of decimals to round ingredients.'), # 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 'comments': _('If you want to be able to create and see comments underneath recipes.'), # noqa: E501
'shopping_auto_sync': _( '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 '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 '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 = { widgets = {
'plan_share': MultiSelectWidget 'plan_share': MultiSelectWidget,
'shopping_share': MultiSelectWidget,
} }
@ -262,6 +266,7 @@ class SyncForm(forms.ModelForm):
} }
# TODO deprecate
class BatchEditForm(forms.Form): class BatchEditForm(forms.Form):
search = forms.CharField(label=_('Search String')) search = forms.CharField(label=_('Search String'))
keywords = forms.ModelMultipleChoiceField( keywords = forms.ModelMultipleChoiceField(
@ -298,6 +303,7 @@ class ImportRecipeForm(forms.ModelForm):
} }
# TODO deprecate
class MealPlanForm(forms.ModelForm): class MealPlanForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
space = kwargs.pop('space') space = kwargs.pop('space')
@ -420,10 +426,8 @@ class UserCreateForm(forms.Form):
class SearchPreferenceForm(forms.ModelForm): class SearchPreferenceForm(forms.ModelForm):
prefix = 'search' prefix = 'search'
trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}),
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).'))
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) preset = forms.CharField(widget=forms.HiddenInput(), required=False)
class Meta: class Meta:
@ -465,3 +469,59 @@ class SearchPreferenceForm(forms.ModelForm):
'trigram': MultiSelectWidget, 'trigram': MultiSelectWidget,
'fulltext': 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/ Source: https://djangosnippets.org/snippets/1703/
""" """
from django.conf import settings from django.conf import settings
from django.core.cache import caches
from cookbook.models import ShareLink
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.decorators import user_passes_test
from django.core.cache import caches
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import reverse, reverse_lazy 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 import permissions
from rest_framework.permissions import SAFE_METHODS from rest_framework.permissions import SAFE_METHODS
from cookbook.models import ShareLink
def get_allowed_groups(groups_required): def get_allowed_groups(groups_required):
""" """
@ -79,7 +79,11 @@ def is_object_shared(user, obj):
# share checks for relevant objects # share checks for relevant objects
if not user.is_authenticated: if not user.is_authenticated:
return False return False
return user in obj.get_shared() if obj.__class__.__name__ == 'ShoppingListEntry':
# shopping lists are shared all or none and stored in user preferences
return obj.created_by in user.get_shopping_share()
else:
return user in obj.get_shared()
def share_link_valid(recipe, share): def share_link_valid(recipe, share):

View File

@ -8,24 +8,13 @@ from django.db.models.functions import Coalesce
from django.utils import timezone, translation from django.utils import timezone, translation
from cookbook.filters import RecipeFilter from cookbook.filters import RecipeFilter
from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.helper.permission_helper import has_group_permission from cookbook.helper.permission_helper import has_group_permission
from cookbook.managers import DICTIONARY from cookbook.managers import DICTIONARY
from cookbook.models import Food, Keyword, Recipe, SearchPreference, ViewLog from cookbook.models import Food, Keyword, Recipe, SearchPreference, ViewLog
from recipes import settings 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 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 # TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
def search_recipes(request, queryset, params): def search_recipes(request, queryset, params):
@ -49,7 +38,7 @@ def search_recipes(request, queryset, params):
search_internal = str2bool(params.get('internal', False)) search_internal = str2bool(params.get('internal', False))
search_random = str2bool(params.get('random', False)) search_random = str2bool(params.get('random', False))
search_new = str2bool(params.get('new', 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 = [] orderby = []
# only sort by recent not otherwise filtering/sorting # only sort by recent not otherwise filtering/sorting
@ -208,6 +197,7 @@ def search_recipes(request, queryset, params):
return queryset 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): def get_facet(qs=None, request=None, use_cache=True, hash_key=None):
""" """
Gets an annotated list from a queryset. 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 traceback
import uuid import uuid
from io import BytesIO, StringIO from io import BytesIO, StringIO
from zipfile import ZipFile, BadZipFile from zipfile import BadZipFile, ZipFile
from bs4 import Tag from bs4 import Tag
from django.core.exceptions import ObjectDoesNotExist 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 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_user_name', get_user_name)
auth.models.User.add_to_class('get_shopping_share', get_shopping_share)
def get_model_name(model): def get_model_name(model):
@ -78,6 +91,13 @@ class TreeModel(MP_Node):
else: else:
return f"{self.name}" 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 @property
def parent(self): def parent(self):
parent = self.get_parent() parent = self.get_parent()
@ -124,6 +144,47 @@ class TreeModel(MP_Node):
with scopes_disabled(): with scopes_disabled():
return super().add_root(**kwargs) 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: class Meta:
abstract = True abstract = True
@ -157,6 +218,18 @@ class PermissionModelMixin:
raise NotImplementedError('get space for method not implemented and standard fields not available') 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): class Space(ExportModelOperationsMixin('space'), models.Model):
name = models.CharField(max_length=128, default='Default') name = models.CharField(max_length=128, default='Default')
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True) 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) max_users = models.IntegerField(default=0)
allow_sharing = models.BooleanField(default=True) allow_sharing = models.BooleanField(default=True)
demo = models.BooleanField(default=False) demo = models.BooleanField(default=False)
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
def __str__(self): def __str__(self):
return self.name return self.name
@ -245,10 +319,18 @@ class UserPreference(models.Model, PermissionModelMixin):
plan_share = models.ManyToManyField( plan_share = models.ManyToManyField(
User, blank=True, related_name='plan_share_default' 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) ingredient_decimals = models.IntegerField(default=2)
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT) comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
shopping_auto_sync = models.IntegerField(default=5) shopping_auto_sync = models.IntegerField(default=5)
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT) 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) created_at = models.DateTimeField(auto_now_add=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=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) name = models.CharField(max_length=64)
icon = models.CharField(max_length=16, blank=True, null=True) icon = models.CharField(max_length=16, blank=True, null=True)
description = models.TextField(default="", blank=True) description = models.TextField(default="", blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True) # TODO deprecate
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True) # TODO deprecate
space = models.ForeignKey(Space, on_delete=models.CASCADE) space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space', _manager_class=TreeManager) objects = ScopedManager(space='space', _manager_class=TreeManager)
@ -393,6 +475,10 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): 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: if SORT_TREE_BY_NAME:
node_order_by = ['name'] node_order_by = ['name']
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)]) 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) supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
ignore_shopping = models.BooleanField(default=False) ignore_shopping = models.BooleanField(default=False)
description = models.TextField(default='', blank=True) 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) space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space', _manager_class=TreeManager) objects = ScopedManager(space='space', _manager_class=TreeManager)
@ -413,6 +502,38 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
else: else:
return super().delete() 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: class Meta:
constraints = [ constraints = [
models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space') 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): def __str__(self):
return self.name 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(): class Meta():
indexes = ( indexes = (
GinIndex(fields=["name_search_vector"]), GinIndex(fields=["name_search_vector"]),
@ -552,7 +688,7 @@ class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionMod
objects = ScopedManager(space='recipe__space') objects = ScopedManager(space='recipe__space')
@staticmethod @ staticmethod
def get_space_key(): def get_space_key():
return 'recipe', 'space' return 'recipe', 'space'
@ -600,7 +736,7 @@ class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, Pe
objects = ScopedManager(space='book__space') objects = ScopedManager(space='book__space')
@staticmethod @ staticmethod
def get_space_key(): def get_space_key():
return 'book', 'space' return 'book', 'space'
@ -647,6 +783,18 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
space = models.ForeignKey(Space, on_delete=models.CASCADE) space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space') 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): def get_label(self):
if self.title: if self.title:
return 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): 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) 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') objects = ScopedManager(space='recipe__space')
@staticmethod @ staticmethod
def get_space_key(): def get_space_key():
return 'recipe', 'space' return 'recipe', 'space'
@ -677,22 +827,101 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
def get_owner(self): def get_owner(self):
try: try:
return self.shoppinglist_set.first().created_by return self.entries.first().created_by or self.shoppinglist_set.first().created_by
except AttributeError: except AttributeError:
return None return None
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin): 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) 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) amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
order = models.IntegerField(default=0) order = models.IntegerField(default=0)
checked = models.BooleanField(default=False) 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(): def get_space_key():
return 'shoppinglist', 'space' return 'shoppinglist', 'space'
@ -702,12 +931,14 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
def __str__(self): def __str__(self):
return f'Shopping list entry {self.id}' return f'Shopping list entry {self.id}'
# TODO deprecate
def get_shared(self): def get_shared(self):
return self.shoppinglist_set.first().shared.all() return self.shoppinglist_set.first().shared.all()
# TODO deprecate
def get_owner(self): def get_owner(self):
try: try:
return self.shoppinglist_set.first().created_by return self.created_by or self.shoppinglist_set.first().created_by
except AttributeError: except AttributeError:
return None return None
@ -863,7 +1094,7 @@ class SearchFields(models.Model, PermissionModelMixin):
def __str__(self): def __str__(self):
return _(self.name) return _(self.name)
@staticmethod @ staticmethod
def get_name(self): def get_name(self):
return _(self.name) return _(self.name)

View File

@ -4,6 +4,7 @@ from decimal import Decimal
from gettext import gettext as _ from gettext import gettext as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import transaction
from django.db.models import Avg, QuerySet, Sum from django.db.models import Avg, QuerySet, Sum
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -11,12 +12,13 @@ from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import NotFound, ValidationError from rest_framework.exceptions import NotFound, ValidationError
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, ImportLog, from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
Ingredient, Keyword, MealPlan, MealType, NutritionInformation, Recipe, FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink, ShoppingList, NutritionInformation, Recipe, RecipeBook, RecipeBookEntry,
ShoppingListEntry, ShoppingListRecipe, Step, Storage, Supermarket, RecipeImport, ShareLink, ShoppingList, ShoppingListEntry,
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit, ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
UserFile, UserPreference, ViewLog) SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
UserPreference, ViewLog)
from cookbook.templatetags.custom_tags import markdown from cookbook.templatetags.custom_tags import markdown
@ -61,7 +63,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
# probably not a tree # probably not a tree
pass pass
if recipes.count() != 0: if recipes.count() != 0:
return random.choice(recipes).image.url return recipes.order_by('?')[:1][0].image.url
else: else:
return None return None
@ -78,7 +80,7 @@ class CustomDecimalField(serializers.Field):
def to_representation(self, value): def to_representation(self, value):
if not isinstance(value, Decimal): if not isinstance(value, Decimal):
value = Decimal(value) value = Decimal(value)
return round(value, 2).normalize() return round(value, 3).normalize()
def to_internal_value(self, data): def to_internal_value(self, data):
if type(data) == int or type(data) == float: if type(data) == int or type(data) == float:
@ -136,8 +138,27 @@ class UserNameSerializer(WritableNestedModelSerializer):
fields = ('id', 'username') 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): 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): def create(self, validated_data):
if validated_data['user'] != self.context['request'].user: if validated_data['user'] != self.context['request'].user:
@ -149,7 +170,8 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
fields = ( fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'user', 'theme', 'nav_color', 'default_unit', 'default_page',
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals', '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): class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
label = serializers.SerializerMethodField('get_label') label = serializers.SerializerMethodField('get_label')
# image = serializers.SerializerMethodField('get_image')
# numrecipe = serializers.SerializerMethodField('count_recipes')
recipe_filter = 'keywords' recipe_filter = 'keywords'
def get_label(self, obj): def get_label(self, obj):
return str(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): def create(self, validated_data):
# since multi select tags dont have id's # since multi select tags dont have id's
# duplicate names might be routed to create # duplicate names might be routed to create
@ -285,27 +293,14 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
class Meta: class Meta:
model = Keyword model = Keyword
fields = ( fields = (
'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at', 'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe')
'updated_at') read_only_fields = ('id', 'label', 'image', 'parent', 'numchild', 'numrecipe')
read_only_fields = ('id', 'numchild', 'parent', 'image')
class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin): class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
# image = serializers.SerializerMethodField('get_image')
# numrecipe = serializers.SerializerMethodField('count_recipes')
recipe_filter = 'steps__ingredients__unit' 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): def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip() validated_data['name'] = validated_data['name'].strip()
validated_data['space'] = self.context['request'].space validated_data['space'] = self.context['request'].space
@ -369,27 +364,13 @@ class RecipeSimpleSerializer(serializers.ModelSerializer):
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin): class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
recipe = RecipeSimpleSerializer(allow_null=True, required=False) recipe = RecipeSimpleSerializer(allow_null=True, required=False)
# image = serializers.SerializerMethodField('get_image') shopping = serializers.SerializerMethodField('get_shopping_status')
# numrecipe = serializers.SerializerMethodField('count_recipes') ignore_inherit = FoodInheritFieldSerializer(many=True)
recipe_filter = 'steps__ingredients__food' recipe_filter = 'steps__ingredients__food'
# def get_image(self, obj): def get_shopping_status(self, obj):
# if obj.recipe and obj.space == obj.recipe.space: return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0
# 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 create(self, validated_data): def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip() validated_data['name'] = validated_data['name'].strip()
@ -403,16 +384,17 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
return obj return obj
def update(self, instance, validated_data): 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) return super(FoodSerializer, self).update(instance, validated_data)
class Meta: class Meta:
model = Food model = Food
fields = ( fields = (
'id', 'name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category', 'image', 'parent', 'id', 'name', 'description', 'shopping', 'recipe', 'ignore_shopping', 'supermarket_category',
'numchild', 'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'inherit', 'ignore_inherit',
'numrecipe') )
read_only_fields = ('id', 'numchild', 'parent', 'image') read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
class IngredientSerializer(WritableNestedModelSerializer): class IngredientSerializer(WritableNestedModelSerializer):
@ -559,6 +541,9 @@ class RecipeSerializer(RecipeBaseSerializer):
validated_data['space'] = self.context['request'].space validated_data['space'] = self.context['request'].space
return super().create(validated_data) return super().create(validated_data)
def update(self, instance, validated_data):
return super().update(instance, validated_data)
class RecipeImageSerializer(WritableNestedModelSerializer): class RecipeImageSerializer(WritableNestedModelSerializer):
class Meta: class Meta:
@ -628,7 +613,10 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
def create(self, validated_data): def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user 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: class Meta:
model = MealPlan model = MealPlan
@ -640,34 +628,98 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
read_only_fields = ('created_by',) read_only_fields = ('created_by',)
# TODO deprecate
class ShoppingListRecipeSerializer(serializers.ModelSerializer): class ShoppingListRecipeSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField('get_name') # should this be done at the front end?
recipe_name = serializers.ReadOnlyField(source='recipe.name') recipe_name = serializers.ReadOnlyField(source='recipe.name')
mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
servings = CustomDecimalField() 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: class Meta:
model = ShoppingListRecipe model = ShoppingListRecipe
fields = ('id', 'recipe', 'recipe_name', 'servings') fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note')
read_only_fields = ('id',) read_only_fields = ('id',)
class ShoppingListEntrySerializer(WritableNestedModelSerializer): class ShoppingListEntrySerializer(WritableNestedModelSerializer):
food = FoodSerializer(allow_null=True) food = FoodSerializer(allow_null=True)
unit = UnitSerializer(allow_null=True, required=False) 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() 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: class Meta:
model = ShoppingListEntry model = ShoppingListEntry
fields = ( 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 ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ShoppingListEntry model = ShoppingListEntry
fields = ('id', 'checked') fields = ('id', 'checked')
# TODO deprecate
class ShoppingListSerializer(WritableNestedModelSerializer): class ShoppingListSerializer(WritableNestedModelSerializer):
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True) recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
entries = ShoppingListEntrySerializer(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',) read_only_fields = ('id', 'created_by',)
# TODO deprecate
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer): class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True) entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
@ -802,7 +855,7 @@ class FoodExportSerializer(FoodSerializer):
class Meta: class Meta:
model = Food model = Food
fields = ('name', 'ignore_shopping', 'supermarket_category') fields = ('name', 'ignore_shopping', 'supermarket_category', 'on_hand')
class IngredientExportSerializer(WritableNestedModelSerializer): class IngredientExportSerializer(WritableNestedModelSerializer):
@ -847,3 +900,24 @@ class RecipeExportSerializer(WritableNestedModelSerializer):
validated_data['created_by'] = self.context['request'].user validated_data['created_by'] = self.context['request'].user
validated_data['space'] = self.context['request'].space validated_data['space'] = self.context['request'].space
return super().create(validated_data) 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.contrib.postgres.search import SearchVector
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import translation from django.utils import translation
from cookbook.models import Recipe, Step
from cookbook.managers import DICTIONARY 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 # TODO there is probably a way to generalize this
@receiver(post_save, sender=Recipe) @receiver(post_save, sender=Recipe)
@skip_signal
def update_recipe_search_vector(sender, instance=None, created=False, **kwargs): 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') language = DICTIONARY.get(translation.get_language(), 'simple')
instance.name_search_vector = SearchVector('name__unaccent', weight='A', config=language) instance.name_search_vector = SearchVector('name__unaccent', weight='A', config=language)
instance.desc_search_vector = SearchVector('description__unaccent', weight='C', config=language) instance.desc_search_vector = SearchVector('description__unaccent', weight='C', config=language)
try: try:
instance._dirty = True instance.skip_signal = True
instance.save() instance.save()
finally: finally:
del instance._dirty del instance.skip_signal
@receiver(post_save, sender=Step) @receiver(post_save, sender=Step)
@skip_signal
def update_step_search_vector(sender, instance=None, created=False, **kwargs): 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: if not instance:
return return
# needed to ensure search vector update doesn't trigger recursion inherit = Food.inherit_fields.difference(instance.ignore_inherit.all())
if hasattr(instance, '_dirty'): # 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 return
language = DICTIONARY.get(translation.get_language(), 'simple') inherit = inherit.values_list('field', flat=True)
instance.search_vector = SearchVector('instruction__unaccent', weight='B', config=language) # apply changes from parent to instance for each inheritted field
if instance.inherit and instance.parent and inherit.count() > 0:
parent = instance.get_parent()
if 'ignore_shopping' in inherit:
instance.ignore_shopping = parent.ignore_shopping
# if supermarket_category is not set, do not cascade - if this becomes non-intuitive can change
if 'supermarket_category' in inherit and parent.supermarket_category:
instance.supermarket_category = parent.supermarket_category
try:
instance.skip_signal = True
instance.save()
finally:
del instance.skip_signal
try: # apply changes to direct children - depend on save signals for those objects to cascade inheritance down
instance._dirty = True instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='ignore_shopping').update(ignore_shopping=instance.ignore_shopping)
instance.save() # don't cascade empty supermarket category
finally: if instance.supermarket_category:
del instance._dirty 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%} {% user_prefs request as prefs%}
{{ prefs|json_script:'user_preference' }} {{ prefs|json_script:'user_preference' }}
</div> </div>
{% block script %} {% block script %}
{% endblock script %} {% endblock script %}
<script type="application/javascript"> <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 %} {% endif %}
<div class="table-container"> <div class="table-container">
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %} <span class="col col-md-9">
{% if create_url %} <h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i> {% 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> </a>
{% endif %} </span>
</h3> {% endif %}
{% if filter %} {% if filter %}
<br/> <br/>

View File

@ -48,6 +48,13 @@
aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}"> aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}">
{% trans 'Search-Settings' %}</a> {% trans 'Search-Settings' %}</a>
</li> </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> </ul>
@ -195,6 +202,17 @@
class="fas fa-save"></i> {% trans 'Save' %}</button> class="fas fa-save"></i> {% trans 'Save' %}</button>
</form> </form>
</div> </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> </div>
@ -224,5 +242,26 @@
$('.nav-tabs a').on('shown.bs.tab', function (e) { $('.nav-tabs a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash; 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> </script>
{% endblock %} {% endblock %}

View File

@ -655,6 +655,7 @@
if (this.shopping_list.entries.length === 0) { if (this.shopping_list.entries.length === 0) {
this.edit_mode = true this.edit_mode = true
} }
console.log(response.data)
}).catch((err) => { }).catch((err) => {
console.log(err) console.log(err)
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger') 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,165 +1,188 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load django_tables2 %} {% load django_tables2 %}
{% load crispy_forms_tags %}
{% load crispy_forms_filters %} {% load crispy_forms_filters %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Space Settings" %}{% endblock %} {%block title %} {% trans "Space Settings" %} {% endblock %}
{% block extra_head %} {% block extra_head %}
{{ form.media }} {{ form.media }}
{{ space_form.media }}
{% include 'include/vue_base.html' %} {% include 'include/vue_base.html' %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li> <li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li>
</ol> </ol>
</nav> </nav>
<h3><span class="text-muted">{% trans 'Space:' %}</span> {{ request.space.name }} <small>{% if HOSTED %} <h3>
<a href="https://tandoor.dev/manage">{% trans 'Manage Subscription' %}</a>{% endif %}</small></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="col-md-6">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">{% trans 'Number of objects' %}</div>
{% trans 'Number of objects' %} <ul class="list-group list-group-flush">
</div> <li class="list-group-item">
<ul class="list-group list-group-flush"> {% trans 'Recipes' %} :
<li class="list-group-item">{% trans 'Recipes' %} : <span <span class="badge badge-pill badge-info"
class="badge badge-pill badge-info">{{ counts.recipes }} / >{{ counts.recipes }} / {% if request.space.max_recipes > 0 %} {{ request.space.max_recipes }}{%
{% if request.space.max_recipes > 0 %} else %}∞{% endif %}</span
{{ request.space.max_recipes }}{% else %}∞{% endif %}</span></li> >
<li class="list-group-item">{% trans 'Keywords' %} : <span </li>
class="badge badge-pill badge-info">{{ counts.keywords }}</span></li> <li class="list-group-item">
<li class="list-group-item">{% trans 'Units' %} : <span {% trans 'Keywords' %} : <span class="badge badge-pill badge-info">{{ counts.keywords }}</span>
class="badge badge-pill badge-info">{{ counts.units }}</span></li> </li>
<li class="list-group-item">{% trans 'Ingredients' %} : <span <li class="list-group-item">
class="badge badge-pill badge-info">{{ counts.ingredients }}</span></li> {% trans 'Units' %} : <span class="badge badge-pill badge-info">{{ counts.units }}</span>
<li class="list-group-item">{% trans 'Recipe Imports' %} : <span </li>
class="badge badge-pill badge-info">{{ counts.recipe_import }}</span></li> <li class="list-group-item">
</ul> {% trans 'Ingredients' %} :
</div> <span class="badge badge-pill badge-info">{{ counts.ingredients }}</span>
</div> </li>
<div class="col-md-6"> <li class="list-group-item">
<div class="card"> {% trans 'Recipe Imports' %} :
<div class="card-header"> <span class="badge badge-pill badge-info">{{ counts.recipe_import }}</span>
{% trans 'Objects stats' %} </li>
</div> </ul>
<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>
</ul>
</div>
</div> </div>
</div> </div>
<br/> <div class="col-md-6">
<br/> <div class="card">
<div class="row"> <div class="card-header">{% trans 'Objects stats' %}</div>
<div class="col col-md-12"> <ul class="list-group list-group-flush">
<li class="list-group-item">
<h4>{% trans 'Members' %} <small class="text-muted">{{ space_users|length }}/ {% trans 'Recipes without Keywords' %} :
{% if request.space.max_users > 0 %} <span class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span>
{{ request.space.max_users }}{% else %}∞{% endif %}</small> </li>
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"><i <li class="list-group-item">
class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a> {% trans 'External Recipes' %} :
</h4> <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> </div>
<br> </div>
<br />
<div class="row"> <br />
<div class="col col-md-12"> <form action="." method="post">{% csrf_token %} {{ user_name_form|crispy }}</form>
{% if space_users %} <div class="row">
<table class="table table-bordered"> <div class="col col-md-12">
<tr> <h4>
<th>{% trans 'User' %}</th> {% trans 'Members' %}
<th>{% trans 'Groups' %}</th> <small class="text-muted"
<th>{% trans 'Edit' %}</th> >{{ space_users|length }}/ {% if request.space.max_users > 0 %} {{ request.space.max_users }}{% else
</tr> %}∞{% endif %}</small
{% for u in space_users %} >
<tr> <a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"
<td> ><i class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a
{{ u.user.username }} >
</td> </h4>
<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">
<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>
</span>
</div>
{% else %}
{% trans 'You cannot edit yourself.' %}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>{% trans 'There are no members in your space yet!' %}</p>
{% endif %}
</div>
</div> </div>
</div>
<br />
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
<h4>{% trans 'Invite Links' %}</h4> {% if space_users %}
{% render_table invite_links %} <table class="table table-bordered">
</div> <tr>
<th>{% trans 'User' %}</th>
<th>{% trans 'Groups' %}</th>
<th>{% trans 'Edit' %}</th>
</tr>
{% for u in space_users %}
<tr>
<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">
<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
>
</span>
</div>
{% else %} {% trans 'You cannot edit yourself.' %} {% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>{% trans 'There are no members in your space yet!' %}</p>
{% endif %}
</div> </div>
</div>
<br/> <div class="row">
<br/> <div class="col col-md-12">
<br/> <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 />
{% endblock %} {% block script %}
<script type="application/javascript">
let app = new Vue({
delimiters: ['[[', ']]'],
el: '#id_base_container',
data: {
users: {
{% for u in space_users %}
'{{ u.pk }}': 'none',
{% endfor %}
}
},
mounted: function () {
},
methods: {
editUserUrl: function (user_id, space_id) {
return '{% url 'change_space_member' 1234 5678 'role' %}'.replace('1234', user_id).replace('5678', space_id).replace('role', this.users[user_id])
}
}
});
</script>
{% endblock %} {% endblock %}
{% block script %}
<script type="application/javascript">
let app = new Vue({
delimiters: ['[[', ']]'],
el: '#id_base_container',
data: {
users: {
{% for u in space_users %}
'{{ u.pk }}': 'none',
{% endfor %}
}
},
mounted: function () {
},
methods: {
editUserUrl: function (user_id, space_id) {
return '{% url 'change_space_member' 1234 5678 'role' %}'.replace('1234', user_id).replace('5678', space_id).replace('role', this.users[user_id])
}
}
});
</script>
{% endblock %}

View File

@ -1,10 +1,9 @@
import json import json
import pytest import pytest
from django.contrib import auth from django.contrib import auth
from django_scopes import scopes_disabled
from django.urls import reverse from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry
@ -74,7 +73,7 @@ def ing_1_1_s1(obj_1_1, space_1):
@pytest.fixture() @pytest.fixture()
def sle_1_s1(obj_1, u1_s1, space_1): 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 = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e) s.entries.add(e)
return e return e
@ -82,12 +81,12 @@ def sle_1_s1(obj_1, u1_s1, space_1):
@pytest.fixture() @pytest.fixture()
def sle_2_s1(obj_2, u1_s1, space_1): 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() @pytest.fixture()
def sle_3_s2(obj_3, u1_s2, space_2): 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 = ShoppingList.objects.create(created_by=auth.get_user(u1_s2), space=space_2, )
s.entries.add(e) s.entries.add(e)
return e return e
@ -95,7 +94,7 @@ def sle_3_s2(obj_3, u1_s2, space_2):
@pytest.fixture() @pytest.fixture()
def sle_1_1_s1(obj_1_1, u1_s1, space_1): 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 = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e) s.entries.add(e)
return 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 assert response['count'] == 4
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}&query={obj_2.name[4:]}').content) response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}&query={obj_2.name[4:]}').content)
assert response['count'] == 4 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 r.status_code == 204
assert not Recipe.objects.filter(pk=recipe_1_s1.id).exists() 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() @pytest.fixture()
def obj_1(space_1, u1_s1): 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 = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e) s.entries.add(e)
return e return e
@ -22,7 +22,7 @@ def obj_1(space_1, u1_s1):
@pytest.fixture @pytest.fixture
def obj_2(space_1, u1_s1): 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 = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e) s.entries.add(e)
return e return e
@ -45,8 +45,11 @@ def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
with scopes_disabled(): with scopes_disabled():
s = ShoppingList.objects.first() s = ShoppingList.objects.first()
e = ShoppingListEntry.objects.first()
s.space = space_2 s.space = space_2
e.space = space_2
s.save() s.save()
e.save()
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1 assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0 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 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): 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 json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 0 assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 0
with scopes_disabled(): with scopes_disabled():
recipe_1_s1.space = space_2 recipe_1_s1.space = space_2
@ -32,9 +32,9 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
Step.objects.update(space=Subquery(Step.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1])) 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])) 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 json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 0
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 2 assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
@pytest.mark.parametrize("arg", [ @pytest.mark.parametrize("arg", [
['a_u', 403], ['a_u', 403],

View File

@ -49,7 +49,7 @@ def ing_3_s2(obj_3, space_2, u2_s2):
@pytest.fixture() @pytest.fixture()
def sle_1_s1(obj_1, u1_s1, space_1): 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 = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e) s.entries.add(e)
return e return e
@ -57,12 +57,12 @@ def sle_1_s1(obj_1, u1_s1, space_1):
@pytest.fixture() @pytest.fixture()
def sle_2_s1(obj_2, u1_s1, space_1): 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() @pytest.fixture()
def sle_3_s2(obj_3, u2_s2, space_2): 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 = ShoppingList.objects.create(created_by=auth.get_user(u2_s2), space=space_2)
s.entries.add(e) s.entries.add(e)
return e return e

View File

@ -1,5 +1,3 @@
from cookbook.models import UserPreference
import json import json
import pytest import pytest
@ -7,6 +5,8 @@ from django.contrib import auth
from django.urls import reverse from django.urls import reverse
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from cookbook.models import UserPreference
LIST_URL = 'api:userpreference-list' LIST_URL = 'api:userpreference-list'
DETAIL_URL = 'api:userpreference-detail' DETAIL_URL = 'api:userpreference-detail'
@ -109,3 +109,6 @@ def test_preference_delete(u1_s1, u2_s1):
) )
) )
assert r.status_code == 204 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 from .views import api, data, delete, edit, import_export, lists, new, telegram, views
router = routers.DefaultRouter() router = routers.DefaultRouter()
router.register(r'user-name', api.UserNameViewSet, basename='username') router.register(r'automation', api.AutomationViewSet)
router.register(r'user-preference', api.UserPreferenceViewSet) router.register(r'bookmarklet-import', api.BookmarkletImportViewSet)
router.register(r'storage', api.StorageViewSet) router.register(r'cook-log', api.CookLogViewSet)
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'food', api.FoodViewSet) router.register(r'food', api.FoodViewSet)
router.register(r'step', api.StepViewSet) router.register(r'food-inherit-field', api.FoodInheritFieldViewSet)
router.register(r'recipe', api.RecipeViewSet) router.register(r'import-log', api.ImportLogViewSet)
router.register(r'ingredient', api.IngredientViewSet) router.register(r'ingredient', api.IngredientViewSet)
router.register(r'keyword', api.KeywordViewSet)
router.register(r'meal-plan', api.MealPlanViewSet) router.register(r'meal-plan', api.MealPlanViewSet)
router.register(r'meal-type', api.MealTypeViewSet) 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', api.ShoppingListViewSet)
router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet) router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet)
router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet) router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
router.register(r'view-log', api.ViewLogViewSet) router.register(r'step', api.StepViewSet)
router.register(r'cook-log', api.CookLogViewSet) router.register(r'storage', api.StorageViewSet)
router.register(r'recipe-book', api.RecipeBookViewSet)
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
router.register(r'supermarket', api.SupermarketViewSet) router.register(r'supermarket', api.SupermarketViewSet)
router.register(r'supermarket-category', api.SupermarketCategoryViewSet) router.register(r'supermarket-category', api.SupermarketCategoryViewSet)
router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet) router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet)
router.register(r'import-log', api.ImportLogViewSet) router.register(r'sync', api.SyncViewSet)
router.register(r'bookmarklet-import', api.BookmarkletImportViewSet) router.register(r'sync-log', api.SyncLogViewSet)
router.register(r'unit', api.UnitViewSet)
router.register(r'user-file', api.UserFileViewSet) 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 = [ urlpatterns = [
path('', views.index, name='index'), 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_html_import import get_recipe_from_source
from cookbook.helper.recipe_search import get_facet, old_search, search_recipes from cookbook.helper.recipe_search import get_facet, old_search, search_recipes
from cookbook.helper.recipe_url_import import get_from_scraper from cookbook.helper.recipe_url_import import get_from_scraper
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, ImportLog, Ingredient, from cookbook.helper.shopping_helper import shopping_helper
Keyword, MealPlan, MealType, Recipe, RecipeBook, RecipeBookEntry, from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Step, ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook,
Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation, RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry,
Sync, SyncLog, Unit, UserFile, UserPreference, ViewLog) ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
UserPreference, ViewLog)
from cookbook.provider.dropbox import Dropbox from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud from cookbook.provider.nextcloud import Nextcloud
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer, from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer,
CookLogSerializer, FoodSerializer, ImportLogSerializer, CookLogSerializer, FoodInheritFieldSerializer, FoodSerializer,
FoodShoppingUpdateSerializer, ImportLogSerializer,
IngredientSerializer, KeywordSerializer, MealPlanSerializer, IngredientSerializer, KeywordSerializer, MealPlanSerializer,
MealTypeSerializer, RecipeBookEntrySerializer, MealTypeSerializer, RecipeBookEntrySerializer,
RecipeBookSerializer, RecipeImageSerializer, RecipeBookSerializer, RecipeImageSerializer,
RecipeOverviewSerializer, RecipeSerializer, RecipeOverviewSerializer, RecipeSerializer,
RecipeShoppingUpdateSerializer, RecipeSimpleSerializer,
ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer, ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer,
ShoppingListRecipeSerializer, ShoppingListSerializer, ShoppingListRecipeSerializer, ShoppingListSerializer,
StepSerializer, StorageSerializer, StepSerializer, StorageSerializer,
@ -359,8 +363,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, StandardFilterMixin):
permission_classes = [CustomIsUser] permission_classes = [CustomIsUser]
def get_queryset(self): def get_queryset(self):
self.queryset = self.queryset.filter(space=self.request.space) return self.queryset.filter(space=self.request.space)
return super().get_queryset()
class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin): class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin):
@ -390,6 +393,16 @@ class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin):
pagination_class = DefaultPagination 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): class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
queryset = Food.objects queryset = Food.objects
model = Food model = Food
@ -397,6 +410,23 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
permission_classes = [CustomIsUser] permission_classes = [CustomIsUser]
pagination_class = DefaultPagination 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): def destroy(self, *args, **kwargs):
try: try:
return (super().destroy(self, *args, **kwargs)) return (super().destroy(self, *args, **kwargs))
@ -547,27 +577,18 @@ class RecipeViewSet(viewsets.ModelViewSet):
pagination_class = RecipePagination pagination_class = RecipePagination
# TODO the boolean params below (keywords_or through new) should be updated to boolean types with front end refactored accordingly # TODO the boolean params below (keywords_or through new) should be updated to boolean types with front end refactored accordingly
query_params = [ query_params = [
QueryParam(name='query', description=_( QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
'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='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter.'), QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'),
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='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='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='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
QueryParam(name='keywords_or', description=_( QueryParam(name='keywords_or', description=_('If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided keywords.')),
'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='foods_or', description=_( QueryParam(name='books_or', description=_('If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')),
'If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided foods.')), QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='books_or', description=_( QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
'If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')), QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
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() schema = QueryParamAutoSchema()
@ -625,16 +646,49 @@ class RecipeViewSet(viewsets.ModelViewSet):
return Response(serializer.data) return Response(serializer.data)
return Response(serializer.errors, 400) 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): class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
queryset = ShoppingListRecipe.objects queryset = ShoppingListRecipe.objects
serializer_class = ShoppingListRecipeSerializer serializer_class = ShoppingListRecipeSerializer
permission_classes = [CustomIsOwner | CustomIsShared] permission_classes = [CustomIsOwner | CustomIsShared]
def get_queryset(self): 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( return self.queryset.filter(
Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter( Q(shoppinglist__created_by=self.request.user)
shoppinglist__space=self.request.space).distinct().all() | 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): class ShoppingListEntryViewSet(viewsets.ModelViewSet):
@ -642,35 +696,46 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
serializer_class = ShoppingListEntrySerializer serializer_class = ShoppingListEntrySerializer
permission_classes = [CustomIsOwner | CustomIsShared] permission_classes = [CustomIsOwner | CustomIsShared]
query_params = [ query_params = [
QueryParam(name='id', QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'),
qtype='int'),
QueryParam( QueryParam(
name='checked', name='checked',
description=_( description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
'Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
), ),
QueryParam(name='supermarket', QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
description=_('Returns the shopping list entries sorted by supermarket category order.'),
qtype='int'),
] ]
schema = QueryParamAutoSchema() schema = QueryParamAutoSchema()
def get_queryset(self): def get_queryset(self):
return self.queryset.filter( self.queryset = self.queryset.filter(space=self.request.space)
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(
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): class ShoppingListViewSet(viewsets.ModelViewSet):
queryset = ShoppingList.objects queryset = ShoppingList.objects
serializer_class = ShoppingListSerializer serializer_class = ShoppingListSerializer
permission_classes = [CustomIsOwner | CustomIsShared] permission_classes = [CustomIsOwner | CustomIsShared]
# TODO update to include settings shared user - make both work for a period of time
def get_queryset(self): def get_queryset(self):
return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter( return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
space=self.request.space).distinct() space=self.request.space).distinct()
# TODO deprecate
def get_serializer_class(self): def get_serializer_class(self):
try: try:
autosync = self.request.query_params.get('autosync', False) 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.ingredient_parser import IngredientParser
from cookbook.helper.permission_helper import group_required, has_group_permission from cookbook.helper.permission_helper import group_required, has_group_permission
from cookbook.helper.recipe_url_import import parse_cooktime from cookbook.helper.recipe_url_import import parse_cooktime
from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe, from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe, RecipeImport, Step, Sync,
RecipeImport, Step, Sync, Unit, UserPreference) Unit, UserPreference)
from cookbook.tables import SyncTable from cookbook.tables import SyncTable
from recipes import settings from recipes import settings
@ -111,8 +111,8 @@ def batch_edit(request):
'Batch edit done. %(count)d recipe was updated.', 'Batch edit done. %(count)d recipe was updated.',
'Batch edit done. %(count)d Recipes where updated.', 'Batch edit done. %(count)d Recipes where updated.',
count) % { count) % {
'count': count, 'count': count,
} }
messages.add_message(request, messages.SUCCESS, msg) messages.add_message(request, messages.SUCCESS, msg)
return redirect('data_batch_edit') return redirect('data_batch_edit')

View File

@ -7,10 +7,9 @@ from django_tables2 import RequestConfig
from cookbook.filters import ShoppingListFilter from cookbook.filters import ShoppingListFilter
from cookbook.helper.permission_helper import group_required from cookbook.helper.permission_helper import group_required
from cookbook.models import (InviteLink, RecipeImport, from cookbook.models import InviteLink, RecipeImport, ShoppingList, Storage, SyncLog, UserFile
ShoppingList, Storage, SyncLog, UserFile) from cookbook.tables import (ImportLogTable, InviteLinkTable, RecipeImportTable, ShoppingListTable,
from cookbook.tables import (ImportLogTable, InviteLinkTable, StorageTable)
RecipeImportTable, ShoppingListTable, StorageTable)
@group_required('admin') @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') @group_required('user')
def shopping_list(request): def shopping_list(request):
f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(space=request.space).filter( f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(space=request.space).filter(
@ -204,7 +189,7 @@ def automation(request):
def user_file(request): def user_file(request):
try: try:
current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[ current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[
'file_size_kb__sum'] / 1000 'file_size_kb__sum'] / 1000
except TypeError: except TypeError:
current_file_size_mb = 0 current_file_size_mb = 0
@ -244,11 +229,9 @@ def shopping_list_new(request):
# model-name is the models.js name of the model, probably ALL-CAPS # model-name is the models.js name of the model, probably ALL-CAPS
return render( return render(
request, request,
'generic/checklist_template.html', 'shoppinglist_template.html',
{ {
"title": _("New Shopping List"), "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 rest_framework.authtoken.models import Token
from cookbook.filters import RecipeFilter from cookbook.filters import RecipeFilter
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm, from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingPreferenceForm,
SpaceJoinForm, User, UserCreateForm, UserNameForm, UserPreference, SpaceCreateForm, SpaceJoinForm, SpacePreferenceForm, User,
UserPreferenceForm) UserCreateForm, UserNameForm, UserPreference, UserPreferenceForm)
from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid 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, from cookbook.models import (Comment, CookLog, Food, FoodInheritField, InviteLink, Keyword,
SearchFields, SearchPreference, ShareLink, ShoppingList, Space, Unit, MealPlan, RecipeImport, SearchFields, SearchPreference, ShareLink,
UserFile, ViewLog) ShoppingList, Space, Unit, UserFile, ViewLog)
from cookbook.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall, from cookbook.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall,
ViewLogTable) ViewLogTable)
from cookbook.views.data import Object from cookbook.views.data import Object
@ -304,10 +304,6 @@ def user_settings(request):
up.use_kj = form.cleaned_data['use_kj'] up.use_kj = form.cleaned_data['use_kj']
up.sticky_navbar = form.cleaned_data['sticky_navbar'] 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() up.save()
elif 'user_name_form' in request.POST: elif 'user_name_form' in request.POST:
@ -378,10 +374,28 @@ def user_settings(request):
sp.trigram_threshold = 0.1 sp.trigram_threshold = 0.1
sp.save() 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: if up:
preference_form = UserPreferenceForm(instance=up, space=request.space) preference_form = UserPreferenceForm(instance=up)
shopping_form = ShoppingPreferenceForm(instance=up)
else: else:
preference_form = UserPreferenceForm(space=request.space) 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( fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(
sp.fulltext.all()) sp.fulltext.all())
@ -406,6 +420,7 @@ def user_settings(request):
'user_name_form': user_name_form, 'user_name_form': user_name_form,
'api_token': api_token, 'api_token': api_token,
'search_form': search_form, 'search_form': search_form,
'shopping_form': shopping_form,
'active_tab': active_tab '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()) InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=request.space).all())
RequestConfig(request, paginate={'per_page': 25}).configure(invite_links) 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 # 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) sql = "\033[1;31m[%s]\033[0m %s" % (query['time'], nice_sql)
total_time = total_time + float(query['time']) total_time = total_time + float(query['time'])
while len(sql) > width - indentation: while len(sql) > width - indentation:
#print("%s%s" % (" " * indentation, sql[:width - indentation])) # print("%s%s" % (" " * indentation, sql[:width - indentation]))
sql = 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)) 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 TIME: %s seconds]\033[0m" % replace_tuple)
print("%s\033[1;32m[TOTAL QUERIES: %s]\033[0m" % (" " * indentation, len(connection.queries))) 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 {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css' import 'bootstrap-vue/dist/bootstrap-vue.css'
import {ApiApiFactory} from "@/utils/openapi/api"; import {ApiApiFactory} from "@/utils/openapi/api.ts";
import CookbookSlider from "@/components/CookbookSlider"; import CookbookSlider from "../../components/CookbookSlider";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "../../components/LoadingSpinner";
import {StandardToasts} from "@/utils/utils"; import {StandardToasts} from "../../utils/utils";
Vue.use(BootstrapVue) Vue.use(BootstrapVue)

File diff suppressed because it is too large Load Diff

View File

@ -25,14 +25,7 @@
</h3> </h3>
</div> </div>
<div class="col-md-3" style="position: relative; margin-top: 1vh"> <div class="col-md-3" style="position: relative; margin-top: 1vh">
<b-form-checkbox <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>
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") }} {{ $t("show_split_screen") }}
</b-form-checkbox> </b-form-checkbox>
</div> </div>
@ -42,46 +35,19 @@
<div class="col" :class="{ 'col-md-6': show_split }"> <div class="col" :class="{ 'col-md-6': show_split }">
<!-- model isn't paginated and loads in one API call --> <!-- model isn't paginated and loads in one API call -->
<div v-if="!paginated"> <div v-if="!paginated">
<generic-horizontal-card <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" />
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> </div>
<!-- model is paginated and needs managed --> <!-- 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')"> <generic-infinite-cards v-if="paginated" :card_counts="left_counts" :scroll="show_split" @search="getItems($event, 'left')" @reset="resetList('left')">
<template v-slot:cards> <template v-slot:cards>
<generic-horizontal-card <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" />
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> </template>
</generic-infinite-cards> </generic-infinite-cards>
</div> </div>
<div class="col col-md-6" v-if="show_split"> <div class="col col-md-6" v-if="show_split">
<generic-infinite-cards <generic-infinite-cards v-if="this_model" :card_counts="right_counts" :scroll="show_split" @search="getItems($event, 'right')" @reset="resetList('right')">
v-if="this_model"
:card_counts="right_counts"
:scroll="show_split"
@search="getItems($event, 'right')"
@reset="resetList('right')"
>
<template v-slot:cards> <template v-slot:cards>
<generic-horizontal-card <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" />
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> </template>
</generic-infinite-cards> </generic-infinite-cards>
</div> </div>
@ -98,13 +64,12 @@ import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css" import "bootstrap-vue/dist/bootstrap-vue.css"
import { CardMixin, ApiMixin, getConfig } from "@/utils/utils" import { CardMixin, ApiMixin, getConfig, StandardToasts, getUserPreference, makeToast } from "@/utils/utils"
import { StandardToasts, ToastMixin } from "@/utils/utils"
import GenericInfiniteCards from "@/components/GenericInfiniteCards" import GenericInfiniteCards from "@/components/GenericInfiniteCards"
import GenericHorizontalCard from "@/components/GenericHorizontalCard" import GenericHorizontalCard from "@/components/GenericHorizontalCard"
import GenericModalForm from "@/components/Modals/GenericModalForm" import GenericModalForm from "@/components/Modals/GenericModalForm"
import ModelMenu from "@/components/ModelMenu" import ModelMenu from "@/components/ContextMenu/ModelMenu"
import { ApiApiFactory } from "@/utils/openapi/api" import { ApiApiFactory } from "@/utils/openapi/api"
//import StorageQuota from "@/components/StorageQuota"; //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 // TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly // or i'm capturing it incorrectly
name: "ModelListView", name: "ModelListView",
mixins: [CardMixin, ApiMixin, ToastMixin], mixins: [CardMixin, ApiMixin],
components: { components: { GenericHorizontalCard, GenericModalForm, GenericInfiniteCards, ModelMenu },
GenericHorizontalCard,
GenericModalForm,
GenericInfiniteCards,
ModelMenu,
},
data() { data() {
return { return {
// this.Models and this.Actions inherited from ApiMixin // this.Models and this.Actions inherited from ApiMixin
@ -236,6 +196,7 @@ export default {
} }
}, },
finishAction: function (e) { finishAction: function (e) {
let update = undefined
switch (e?.action) { switch (e?.action) {
case "save": case "save":
this.saveThis(e.form_data) this.saveThis(e.form_data)
@ -263,7 +224,7 @@ export default {
} }
this.clearState() this.clearState()
}, },
getItems: function (params, col) { getItems: function (params = {}, col) {
let column = col || "left" let column = col || "left"
params.options = { query: { extended: 1 } } // returns extended values in API response params.options = { query: { extended: 1 } } // returns extended values in API response
this.genericAPI(this.this_model, this.Actions.LIST, params) this.genericAPI(this.this_model, this.Actions.LIST, params)

View File

@ -629,7 +629,6 @@ export default {
apiFactory.updateRecipe(this.recipe_id, this.recipe, apiFactory.updateRecipe(this.recipe_id, this.recipe,
{}).then((response) => { {}).then((response) => {
console.log(response)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE) StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
this.recipe_changed = false this.recipe_changed = false
if (view_after) { if (view_after) {

View File

@ -1,90 +1,61 @@
<template> <template>
<div id="app"> <div id="app">
<template v-if="loading"> <template v-if="loading">
<loading-spinner></loading-spinner> <loading-spinner></loading-spinner>
</template> </template>
<div v-if="!loading"> <div v-if="!loading">
<div class="row"> <div class="row">
<div class="col-12" style="text-align: center"> <div class="col-12" style="text-align: center">
<h3>{{ recipe.name }}</h3> <h3>{{ recipe.name }}</h3>
</div> </div>
</div>
<div class="row text-center">
<div class="col col-md-12">
<recipe-rating :recipe="recipe"></recipe-rating>
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
</div>
</div>
<div class="my-auto">
<div class="col-12" style="text-align: center">
<i>{{ recipe.description }}</i>
</div>
</div>
<div style="text-align: center">
<keywords-component :recipe="recipe"></keywords-component>
</div>
<hr/>
<div class="row">
<div class="col col-md-3">
<div class="row d-flex" style="padding-left: 16px">
<div class="my-auto" style="padding-right: 4px">
<i class="fas fa-user-clock fa-2x text-primary"></i>
</div> </div>
<div class="my-auto" style="padding-right: 4px">
<span class="text-primary"><b>{{ $t('Preparation') }}</b></span><br/>
{{ recipe.working_time }} {{ $t('min') }}
</div>
</div>
</div>
<div class="col col-md-3"> <div class="row text-center">
<div class="row d-flex"> <div class="col col-md-12">
<div class="my-auto" style="padding-right: 4px"> <recipe-rating :recipe="recipe"></recipe-rating>
<i class="far fa-clock fa-2x text-primary"></i> <last-cooked :recipe="recipe" class="mt-2"></last-cooked>
</div>
</div> </div>
<div class="my-auto" style="padding-right: 4px">
<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="my-auto">
<div class="row d-flex" style="padding-left: 16px"> <div class="col-12" style="text-align: center">
<div class="my-auto" style="padding-right: 4px"> <i>{{ recipe.description }}</i>
<i class="fas fa-pizza-slice fa-2x text-primary"></i> </div>
</div> </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"> <div style="text-align: center">
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu> <keywords :recipe="recipe"></keywords>
</div> </div>
</div>
<hr/>
<div class="row"> <hr />
<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="row">
<div class="card border-primary"> <div class="col col-md-3">
<div class="card-body"> <div class="row d-flex" style="padding-left: 16px">
<div class="row"> <div class="my-auto" style="padding-right: 4px">
<div class="col col-md-8"> <i class="fas fa-user-clock fa-2x text-primary"></i>
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h4> </div>
<div class="my-auto" style="padding-right: 4px">
<span class="text-primary"
><b>{{ $t("Preparation") }}</b></span
><br />
{{ recipe.working_time }} {{ $t("min") }}
</div>
</div>
</div>
<div class="col col-md-3">
<div class="row d-flex">
<div class="my-auto" style="padding-right: 4px">
<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") }}
</div>
</div>
</div> </div>
</div> </div>
<br/> <br/>
@ -94,184 +65,186 @@
<template v-if="s.show_as_header && s.name !== '' && s.ingredients.length > 0"> <template v-if="s.show_as_header && s.name !== '' && s.ingredients.length > 0">
<b v-bind:key="s.id">{{s.name}}</b> <b v-bind:key="s.id">{{s.name}}</b>
</template> </template>
<table class="table table-sm"> <table class="table table-sm">
<template v-for="i in s.ingredients" :key="i.id"> <template v-for="i in s.ingredients" :key="i.id">
<ingredient-component :ingredient="i" :ingredient_factor="ingredient_factor" <ingredient-component :ingredient="i" :ingredient_factor="ingredient_factor"
@checked-state-changed="updateIngredientCheckedState"></ingredient-component> @checked-state-changed="updateIngredientCheckedState"></ingredient-component>
</template> </template>
</table> </table>
</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>
</template>
</div> </div>
</div> <hr />
</div>
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2"> <div class="row">
<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">
<div class="col-12"> <ingredients-card
:steps="recipe.steps"
:recipe="recipe.id"
:ingredient_factor="ingredient_factor"
:servings="servings"
:header="true"
@checked-state-changed="updateIngredientCheckedState"
/>
</div>
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh;" <div class="col-12 order-1 col-sm-12 order-sm-1 col-md-4 order-md-2">
:alt="$t( 'Recipe_Image')" v-if="recipe.image !== null"> <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" />
</div>
</div>
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
<div class="col-12">
<Nutrition :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition>
</div>
</div>
</div>
</div> </div>
</div>
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh"> <template v-if="!recipe.internal">
<div class="col-12"> <div v-if="recipe.file_path.includes('.pdf')">
<Nutrition-component :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition-component> <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')">
<ImageViewer :recipe="recipe"></ImageViewer>
</div>
</template>
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
<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>
</div>
</div> </div>
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
</div> <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">
<template v-if="!recipe.internal"> <a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t("Report Abuse") }}</a>
<div v-if="recipe.file_path.includes('.pdf')"> </div>
<PdfViewer :recipe="recipe"></PdfViewer>
</div> </div>
<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>
</div>
</div> </div>
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
<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>
</div>
</div>
</div>
</template> </template>
<script> <script>
import Vue from 'vue' import Vue from "vue"
import {BootstrapVue} from 'bootstrap-vue' import { BootstrapVue } from "bootstrap-vue"
import 'bootstrap-vue/dist/bootstrap-vue.css' import "bootstrap-vue/dist/bootstrap-vue.css"
import {apiLoadRecipe} from "@/utils/api"; import { apiLoadRecipe } from "@/utils/api"
import Step from "@/components/StepComponent"; import Step from "@/components/Step"
import RecipeContextMenu from "@/components/RecipeContextMenu"; import RecipeContextMenu from "@/components/ContextMenu/RecipeContextMenu"
import {ResolveUrlMixin, ToastMixin} from "@/utils/utils"; import { ResolveUrlMixin, ToastMixin } from "@/utils/utils"
import Ingredient from "@/components/IngredientComponent"; import IngredientsCard from "@/components/IngredientsCard"
import PdfViewer from "@/components/PdfViewer"; import PdfViewer from "@/components/PdfViewer"
import ImageViewer from "@/components/ImageViewer"; import ImageViewer from "@/components/ImageViewer"
import Nutrition from "@/components/NutritionComponent"; import Nutrition from "@/components/Nutrition"
import moment from 'moment' import moment from "moment"
import Keywords from "@/components/KeywordsComponent"; import Keywords from "@/components/Keywords"
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner"
import AddRecipeToBook from "@/components/AddRecipeToBook"; import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
import RecipeRating from "@/components/RecipeRating"; import RecipeRating from "@/components/RecipeRating"
import LastCooked from "@/components/LastCooked"; 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";
Vue.prototype.moment = moment Vue.prototype.moment = moment
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
export default { export default {
name: 'RecipeView', name: "RecipeView",
mixins: [ mixins: [ResolveUrlMixin, ToastMixin],
ResolveUrlMixin, components: {
ToastMixin, LastCooked,
], RecipeRating,
components: { PdfViewer,
LastCooked, ImageViewer,
RecipeRating, IngredientsCard,
PdfViewer, Step,
ImageViewer, RecipeContextMenu,
IngredientComponent, Nutrition,
StepComponent, Keywords,
RecipeContextMenu, LoadingSpinner,
NutritionComponent, AddRecipeToBook,
KeywordsComponent,
LoadingSpinner,
AddRecipeToBook,
},
computed: {
ingredient_factor: function () {
return this.servings / this.recipe.servings
}, },
}, computed: {
data() { ingredient_factor: function () {
return { return this.servings / this.recipe.servings
loading: true, },
recipe: undefined,
ingredient_count: 0,
servings: 1,
start_time: "",
share_uid: window.SHARE_UID
}
},
mounted() {
this.loadRecipe(window.RECIPE_ID)
this.$i18n.locale = window.CUSTOM_LOCALE
},
methods: {
loadRecipe: function (recipe_id) {
apiLoadRecipe(recipe_id).then(recipe => {
if (window.USER_SERVINGS !== 0) {
recipe.servings = window.USER_SERVINGS
}
this.servings = recipe.servings
let total_time = 0
for (let step of recipe.steps) {
this.ingredient_count += step.ingredients.length
for (let ingredient of step.ingredients) {
this.$set(ingredient, 'checked', false)
}
step.time_offset = total_time
total_time += step.time
}
// 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.recipe = recipe
this.loading = false
})
}, },
updateStartTime: function (e) { data() {
this.start_time = e return {
}, loading: true,
updateIngredientCheckedState: function (e) { recipe: undefined,
for (let step of this.recipe.steps) { ingredient_count: 0,
for (let ingredient of step.ingredients) { servings: 1,
if (ingredient.id === e.id) { start_time: "",
this.$set(ingredient, 'checked', !ingredient.checked) share_uid: window.SHARE_UID,
}
} }
}
}, },
} mounted() {
this.loadRecipe(window.RECIPE_ID)
this.$i18n.locale = window.CUSTOM_LOCALE
},
methods: {
loadRecipe: function (recipe_id) {
apiLoadRecipe(recipe_id).then((recipe) => {
if (window.USER_SERVINGS !== 0) {
recipe.servings = window.USER_SERVINGS
}
this.servings = recipe.servings
let total_time = 0
for (let step of recipe.steps) {
this.ingredient_count += step.ingredients.length
for (let ingredient of step.ingredients) {
this.$set(ingredient, "checked", false)
}
step.time_offset = total_time
total_time += step.time
}
// 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.recipe = recipe
this.loading = false
})
},
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)
}
}
}
},
},
} }
</script> </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"/> :item="item"/>
<icon-badge v-if="Icon" <icon-badge v-if="Icon"
:item="item"/> :item="item"/>
<on-hand-badge v-if="OnHand"
:item="item"/>
<shopping-badge v-if="Shopping"
:item="item"/>
</span> </span>
</template> </template>
<script> <script>
import LinkedRecipe from "@/components/Badges/LinkedRecipe"; import LinkedRecipe from "@/components/Badges/LinkedRecipe";
import IconBadge from "@/components/Badges/Icon"; import IconBadge from "@/components/Badges/Icon";
import OnHandBadge from "@/components/Badges/OnHand";
import ShoppingBadge from "@/components/Badges/Shopping";
export default { export default {
name: 'CardBadges', name: 'CardBadges',
components: {LinkedRecipe, IconBadge}, components: {LinkedRecipe, IconBadge, OnHandBadge, ShoppingBadge},
props: { props: {
item: {type: Object}, item: {type: Object},
model: {type: Object} model: {type: Object}
@ -30,6 +36,12 @@ export default {
}, },
Icon: function () { Icon: function () {
return this.model?.badges?.icon ?? false return this.model?.badges?.icon ?? false
},
OnHand: function () {
return this.model?.badges?.on_hand ?? false
},
Shopping: function () {
return this.model?.badges?.shopping ?? false
} }
}, },
watch: { watch: {

View File

@ -1,6 +1,6 @@
<template> <template>
<span> <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}} {{item.icon}}
</b-button> </b-button>
</span> </span>

View File

@ -1,7 +1,7 @@
<template> <template>
<span> <span>
<b-button v-if="item.recipe" v-b-tooltip.hover :title="item.recipe.name" <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> </span>
</template> </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,127 +1,118 @@
<template> <template>
<div <div class="context-menu" ref="popper" v-show="isVisible" tabindex="-1" v-click-outside="close" @contextmenu.capture.prevent>
class="context-menu" <ul class="dropdown-menu" role="menu">
ref="popper" <slot :contextData="contextData" name="menu" />
v-show="isVisible" </ul>
tabindex="-1" </div>
v-click-outside="close"
@contextmenu.capture.prevent>
<ul class="dropdown-menu" role="menu">
<slot :contextData="contextData" name="menu"/>
</ul>
</div>
</template> </template>
<script> <script>
import Popper from 'popper.js'; import Popper from "popper.js"
Popper.Defaults.modifiers.computeStyle.gpuAcceleration = false Popper.Defaults.modifiers.computeStyle.gpuAcceleration = false
import ClickOutside from 'vue-click-outside' import ClickOutside from "vue-click-outside"
export default { export default {
name: "ContextMenu.vue", name: "ContextMenu.vue",
props: { props: {
boundariesElement: { boundariesElement: {
type: String, type: String,
default: 'body', default: "body",
},
},
components: {},
data() {
return {
opened: false,
contextData: {},
};
},
directives: {
ClickOutside,
},
computed: {
isVisible() {
return this.opened;
},
},
methods: {
open(evt, contextData) {
this.opened = true;
this.contextData = contextData;
if (this.popper) {
this.popper.destroy();
}
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
placement: 'right-start',
modifiers: {
preventOverflow: {
boundariesElement: document.querySelector(this.boundariesElement),
},
}, },
});
this.$nextTick(() => {
this.popper.scheduleUpdate();
});
}, },
close() { components: {},
this.opened = false; data() {
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;
function getBoundingClientRect() {
return { return {
left, opened: false,
top, contextData: {},
right, }
bottom,
};
}
const obj = {
getBoundingClientRect,
clientWidth,
clientHeight,
};
return obj;
}, },
}, directives: {
beforeUnmount() { ClickOutside,
if (this.popper !== undefined) { },
this.popper.destroy(); computed: {
} isVisible() {
}, return this.opened
}; },
},
methods: {
open(evt, contextData) {
this.opened = true
this.contextData = contextData
if (this.popper) {
this.popper.destroy()
}
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
placement: "right-start",
modifiers: {
preventOverflow: {
boundariesElement: document.querySelector(this.boundariesElement),
},
},
})
this.$nextTick(() => {
this.popper.scheduleUpdate()
})
},
close() {
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
function getBoundingClientRect() {
return {
left,
top,
right,
bottom,
}
}
const obj = {
getBoundingClientRect,
clientWidth,
clientHeight,
}
return obj
},
},
beforeUnmount() {
if (this.popper !== undefined) {
this.popper.destroy()
}
},
}
</script> </script>
<style scoped> <style scoped>
.context-menu { .context-menu {
position: fixed; position: fixed;
z-index: 999; z-index: 999;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 1px 4px 0 #eee; box-shadow: 0 1px 4px 0 #eee;
} }
.context-menu:focus { .context-menu:focus {
outline: none; outline: none;
} }
.context-menu ul { .context-menu ul {
padding: 0px; padding: 0px;
margin: 0px; margin: 0px;
} }
.dropdown-menu { .dropdown-menu {
display: block; display: block;
position: relative; position: relative;
} }
</style> </style>

View File

@ -1,16 +1,13 @@
<template> <template>
<li @click="$emit('click', $event)" role="presentation"> <li @click="$emit('click', $event)" role="presentation">
<slot/> <slot />
</li> </li>
</template> </template>
<script> <script>
export default { export default {
name: "ContextMenuItem.vue", name: "ContextMenuItem.vue",
} }
</script> </script>
<style scoped> <style scoped></style>
</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') }} <i class="fas fa-shopping-cart fa-fw"></i> {{ $t('Add_to_Shopping') }}
</a> </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') }} class="fas fa-calendar fa-fw"></i> {{ $t('Add_to_Plan') }}
</a> </a>
@ -76,6 +80,7 @@
<meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="[recipe]" <meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="[recipe]"
:entry-editing_initial_meal_type="[]" @save-entry="saveMealPlan" :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> :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> </div>
</template> </template>
@ -84,8 +89,9 @@
import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils"; import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils";
import CookLog from "@/components/CookLog"; import CookLog from "@/components/CookLog";
import axios from "axios"; import axios from "axios";
import AddRecipeToBook from "./AddRecipeToBook"; import AddRecipeToBook from "@/components/Modals/AddRecipeToBook";
import MealPlanEditModal from "@/components/MealPlanEditModal"; import MealPlanEditModal from "@/components/Modals/MealPlanEditModal";
import ShoppingModal from "@/components/Modals/ShoppingModal";
import moment from "moment"; import moment from "moment";
import Vue from "vue"; import Vue from "vue";
import {ApiApiFactory} from "@/utils/openapi/api"; import {ApiApiFactory} from "@/utils/openapi/api";
@ -100,7 +106,8 @@ export default {
components: { components: {
AddRecipeToBook, AddRecipeToBook,
CookLog, CookLog,
MealPlanEditModal MealPlanEditModal,
ShoppingModal
}, },
data() { data() {
return { return {
@ -118,7 +125,7 @@ export default {
servings: 1, servings: 1,
shared: [], shared: [],
title: '', title: '',
title_placeholder: this.$t('Title') title_placeholder: this.$t('Title'),
} }
}, },
entryEditing: {}, entryEditing: {},
@ -177,7 +184,10 @@ export default {
url: this.recipe_share_link url: this.recipe_share_link
} }
navigator.share(shareData) navigator.share(shareData)
} },
addToShopping() {
this.$bvModal.show(`shopping_${this.modal_id}`)
},
} }
} }
</script> </script>

View File

@ -92,13 +92,13 @@
<i class="fas fa-times fa-fw"></i> <b>{{$t('Cancel')}}</b> <i class="fas fa-times fa-fw"></i> <b>{{$t('Cancel')}}</b>
</b-list-group-item> </b-list-group-item>
<!-- TODO add to shopping list --> <!-- TODO add to shopping list -->
<!-- TODO add to and/or manage pantry --> <!-- TODO toggle onhand -->
</b-list-group> </b-list-group>
</div> </div>
</template> </template>
<script> <script>
import GenericContextMenu from "@/components/GenericContextMenu"; import GenericContextMenu from "@/components/ContextMenu/GenericContextMenu";
import Badges from "@/components/Badges"; import Badges from "@/components/Badges";
import GenericPill from "@/components/GenericPill"; import GenericPill from "@/components/GenericPill";
import GenericOrderedPill from "@/components/GenericOrderedPill"; import GenericOrderedPill from "@/components/GenericOrderedPill";

View File

@ -1,88 +1,217 @@
<template> <template>
<tr>
<tr @click="$emit('checked-state-changed', ingredient)"> <template v-if="ingredient.is_header">
<template v-if="ingredient.is_header"> <td colspan="5" @click="done">
<td colspan="5"> <b>{{ ingredient.note }}</b>
<b>{{ ingredient.note }}</b> </td>
</td>
</template>
<template v-else>
<td class="d-print-non" v-if="detailed">
<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>
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
</td>
<td>
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
</td>
<td>
<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>
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
</template> </template>
</td>
<td v-if="detailed">
<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-if="ingredient.note.length > 15" -->
<!-- <span v-else>-->
<!-- {{ ingredient.note }}-->
<!-- </span>-->
<div class="d-none d-print-block"> <template v-else>
<i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }} <td class="d-print-non" v-if="detailed && !add_shopping_mode" @click="done">
</div> <i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
</div> <i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
</td> </td>
</template> <td class="text-nowrap" @click="done">
</tr> <span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
</td>
<td @click="done">
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
</td>
<td @click="done">
<template v-if="ingredient.food !== null">
<!-- <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 && !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-if="ingredient.note.length > 15" -->
<!-- <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>
</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> </template>
<script> <script>
import { calculateAmount, ResolveUrlMixin, ApiMixin } from "@/utils/utils"
import {calculateAmount, ResolveUrlMixin} from "@/utils/utils"; import OnHandBadge from "@/components/Badges/OnHand"
import ShoppingBadge from "@/components/Badges/Shopping"
export default { export default {
name: 'IngredientComponent', name: "Ingredient",
props: { components: { OnHandBadge, ShoppingBadge },
ingredient: Object, props: {
ingredient_factor: { ingredient: Object,
type: Number, ingredient_factor: { type: Number, default: 1 },
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 []
},
}, // list of unchecked ingredients in shopping list
},
mixins: [ResolveUrlMixin, ApiMixin],
data() {
return {
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) {
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")
},
}, },
detailed: {
type: Boolean,
default: true
}
},
mixins: [
ResolveUrlMixin
],
data() {
return {
checked: false
}
},
methods: {
calculateAmount: function (x) {
return calculateAmount(x, this.ingredient_factor)
}
}
} }
</script> </script>
<style scoped> <style scoped>
/* increase size of hover/touchable space without changing spacing */ /* increase size of hover/touchable space without changing spacing */
.touchable { .touchable {
padding-right: 2em; padding-right: 2em;
padding-left: 2em; padding-left: 2em;
margin-right: -2em; margin-right: -2em;
margin-left: -2em; margin-left: -2em;
} }
</style> </style>

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> <script>
import Vue from "vue" import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue" import { BootstrapVue } from "bootstrap-vue"
import { getForm } from "@/utils/utils" import { getForm, formFunctions } from "@/utils/utils"
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
@ -84,6 +84,10 @@ export default {
show: function () { show: function () {
if (this.show) { if (this.show) {
this.form = getForm(this.model, this.action, this.item1, this.item2) 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.dirty = true
this.$bvModal.show("modal_" + this.id) this.$bvModal.show("modal_" + this.id)
} else { } 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,158 +1,137 @@
<template> <template>
<b-card no-body v-hover v-if="recipe">
<a :href="clickUrl()">
<b-card no-body v-hover> <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>
<a :href="clickUrl()"> <div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src=recipe_image <a>
v-bind:alt="$t('Recipe_Image')" <recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
top></b-card-img-lazy> </a>
<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>
</a>
<b-card-body class="p-4">
<h6><a :href="clickUrl()">
<template v-if="recipe !== null">{{ recipe.name }}</template>
<template v-else>{{ meal_plan.title }}</template>
</a></h6>
<b-card-text style="text-overflow: ellipsis;">
<template v-if="recipe !== null">
<recipe-rating :recipe="recipe"></recipe-rating>
<template v-if="recipe.description !== null">
<span v-if="recipe.description.length > text_length">
{{ recipe.description.substr(0, text_length) + "\u2026" }}
</span>
<span v-if="recipe.description.length <= text_length">
{{ recipe.description }}
</span>
</template>
<p class="mt-1">
<last-cooked :recipe="recipe"></last-cooked>
<keywords-component :recipe="recipe" style="margin-top: 4px"></keywords-component>
</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>
</div>
</div> </div>
</transition> <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-badge pill variant="info" v-if="!recipe.internal">{{ $t('External') }}</b-badge> <b-card-body class="p-4">
<!-- <b-badge pill variant="success" <h6>
<a :href="clickUrl()">
<template v-if="recipe !== null">{{ recipe.name }}</template>
<template v-else>{{ meal_plan.title }}</template>
</a>
</h6>
<b-card-text style="text-overflow: ellipsis;">
<template v-if="recipe !== null">
<recipe-rating :recipe="recipe"></recipe-rating>
<template v-if="recipe.description !== null">
<span v-if="recipe.description.length > text_length">
{{ recipe.description.substr(0, text_length) + "\u2026" }}
</span>
<span v-if="recipe.description.length <= text_length">
{{ recipe.description }}
</span>
</template>
<p class="mt-1">
<last-cooked :recipe="recipe"></last-cooked>
<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>
<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="success"
v-if="Date.parse(recipe.created_at) > new Date(Date.now() - (7 * (1000 * 60 * 60 * 24)))"> v-if="Date.parse(recipe.created_at) > new Date(Date.now() - (7 * (1000 * 60 * 60 * 24)))">
{{ $t('New') }} {{ $t('New') }}
</b-badge> --> </b-badge> -->
</template>
<template v-else>{{ meal_plan.note }}</template>
</b-card-text>
</b-card-body>
</template> <b-card-footer v-if="footer_text !== undefined"> <i v-bind:class="footer_icon"></i> {{ footer_text }} </b-card-footer>
<template v-else>{{ meal_plan.note }}</template> </b-card>
</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>
</template> </template>
<script> <script>
import RecipeContextMenu from "@/components/RecipeContextMenu"; import RecipeContextMenu from "@/components/ContextMenu/RecipeContextMenu"
import {resolveDjangoUrl, ResolveUrlMixin} from "@/utils/utils"; import Keywords from "@/components/Keywords"
import RecipeRating from "@/components/RecipeRating"; import { resolveDjangoUrl, ResolveUrlMixin } from "@/utils/utils"
import moment from "moment/moment"; import RecipeRating from "@/components/RecipeRating"
import Vue from "vue"; import moment from "moment/moment"
import LastCooked from "@/components/LastCooked"; import Vue from "vue"
import KeywordsComponent from "@/components/KeywordsComponent"; import LastCooked from "@/components/LastCooked"
import IngredientComponent from "@/components/IngredientComponent"; import IngredientsCard from "@/components/IngredientsCard"
Vue.prototype.moment = moment Vue.prototype.moment = moment
export default { export default {
name: "RecipeCard", name: "RecipeCard",
mixins: [ mixins: [ResolveUrlMixin],
ResolveUrlMixin, components: { LastCooked, RecipeRating, Keywords, RecipeContextMenu, IngredientsCard },
], props: {
components: {LastCooked, RecipeRating, KeywordsComponent, RecipeContextMenu, IngredientComponent}, recipe: Object,
props: { meal_plan: Object,
recipe: Object, footer_text: String,
meal_plan: Object, footer_icon: String,
footer_text: String,
footer_icon: String
},
computed: {
detailed: function () {
return this.recipe.steps !== undefined;
}, },
text_length: function () { computed: {
if (this.detailed) { detailed: function() {
return 200 return this.recipe?.steps !== undefined
} else { },
return 120 text_length: function() {
} if (this.detailed) {
return 200
} else {
return 120
}
},
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() {
if (this.recipe !== null) {
return resolveDjangoUrl("view_recipe", this.recipe.id)
} else {
return resolveDjangoUrl("view_plan_entry", this.meal_plan.id)
}
},
},
directives: {
hover: {
inserted: function(el) {
el.addEventListener("mouseenter", () => {
el.classList.add("shadow")
})
el.addEventListener("mouseleave", () => {
el.classList.remove("shadow")
})
},
},
}, },
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 () {
if (this.recipe !== null) {
return resolveDjangoUrl('view_recipe', this.recipe.id)
} else {
return resolveDjangoUrl('view_plan_entry', this.meal_plan.id)
}
}
},
directives: {
hover: {
inserted: function (el) {
el.addEventListener('mouseenter', () => {
el.classList.add("shadow")
});
el.addEventListener('mouseleave', () => {
el.classList.remove("shadow")
});
}
}
}
} }
</script> </script>
<style scoped> <style scoped>
.fade-enter-active, .fade-leave-active { .fade-enter-active,
transition: opacity .5s; .fade-leave-active {
transition: opacity 0.5s;
} }
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0; opacity: 0;
} }
</style> </style>

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" <div class="col col-md-4"
v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)"> v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
<table class="table table-sm"> <table class="table table-sm">
<!-- eslint-disable vue/no-v-for-template-key-on-child --> <ingredients-card
<template v-for="i in step.ingredients"> :steps="[step]"
<Ingredient-component v-bind:ingredient="i" :ingredient_factor="ingredient_factor" :key="i.id" :ingredient_factor="ingredient_factor"
@checked-state-changed="$emit('checked-state-changed', i)"></Ingredient-component> @checked-state-changed="$emit('checked-state-changed', $event)"
</template> />
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
</table> </table>
</div> </div>
<div class="col" :class="{ 'col-md-8': recipe.steps.length > 1, 'col-md-12': recipe.steps.length <= 1,}"> <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 {GettextMixin} from "@/utils/utils";
import CompileComponent from "@/components/CompileComponent"; import CompileComponent from "@/components/CompileComponent";
import IngredientsCard from "@/components/IngredientsCard";
import Vue from "vue"; import Vue from "vue";
import moment from "moment"; import moment from "moment";
import {ResolveUrlMixin} from "@/utils/utils"; import {ResolveUrlMixin} from "@/utils/utils";
@ -174,10 +174,7 @@ export default {
GettextMixin, GettextMixin,
ResolveUrlMixin, ResolveUrlMixin,
], ],
components: { components: { CompileComponent, IngredientsCard},
IngredientComponent,
CompileComponent,
},
props: { props: {
step: Object, step: Object,
ingredient_factor: Number, ingredient_factor: Number,

View File

@ -173,6 +173,15 @@
"Time": "Time", "Time": "Time",
"Text": "Text", "Text": "Text",
"Shopping_list": "Shopping List", "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", "Create_Meal_Plan_Entry": "Create meal plan entry",
"Edit_Meal_Plan_Entry": "Edit meal plan entry", "Edit_Meal_Plan_Entry": "Edit meal plan entry",
"Title": "Title", "Title": "Title",
@ -194,6 +203,41 @@
"Title_or_Recipe_Required": "Title or recipe selection required", "Title_or_Recipe_Required": "Title or recipe selection required",
"Color": "Color", "Color": "Color",
"New_Meal_Type": "New Meal type", "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", "Week_Numbers": "Week numbers",
"Show_Week_Numbers": "Show week numbers ?", "Show_Week_Numbers": "Show week numbers ?",
"Export_As_ICal": "Export current period to iCal format", "Export_As_ICal": "Export current period to iCal format",

View File

@ -1,6 +1,7 @@
import axios from "axios"; import axios from "axios";
import {djangoGettext as _, makeToast} from "@/utils/utils"; import {djangoGettext as _, makeToast} from "@/utils/utils";
import {resolveDjangoUrl} from "@/utils/utils"; import {resolveDjangoUrl} from "@/utils/utils";
import {ApiApiFactory} from "@/utils/openapi/api.ts";
axios.defaults.xsrfCookieName = 'csrftoken' axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN" axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
@ -47,4 +48,8 @@ function handleError(error, message) {
makeToast('Error', message, 'danger') makeToast('Error', message, 'danger')
console.log(error) 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, merge: true,
badges: { badges: {
linked_recipe: true, linked_recipe: true,
on_hand: true,
shopping: true,
}, },
tags: [{ field: "supermarket_category", label: "name", color: "info" }], tags: [{ field: "supermarket_category", label: "name", color: "info" }],
// REQUIRED: unordered array of fields that can be set during create // REQUIRED: unordered array of fields that can be set during create
create: { create: {
// if not defined partialUpdate will use the same parameters, prepending 'id' // 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: { form: {
name: { name: {
form_field: true, form_field: true,
@ -101,6 +104,12 @@ export class Models {
field: "ignore_shopping", field: "ignore_shopping",
label: i18n.t("Ignore_Shopping"), label: i18n.t("Ignore_Shopping"),
}, },
onhand: {
form_field: true,
type: "checkbox",
field: "on_hand",
label: i18n.t("OnHand"),
},
shopping_category: { shopping_category: {
form_field: true, form_field: true,
type: "lookup", type: "lookup",
@ -109,8 +118,30 @@ export class Models {
label: i18n.t("Shopping_Category"), label: i18n.t("Shopping_Category"),
allow_create: true, 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 = { static KEYWORD = {
@ -180,6 +211,12 @@ export class Models {
static SHOPPING_LIST = { static SHOPPING_LIST = {
name: i18n.t("Shopping_list"), name: i18n.t("Shopping_list"),
apiName: "ShoppingListEntry", apiName: "ShoppingListEntry",
list: {
params: ["id", "checked", "supermarket", "options"],
},
create: {
params: [["amount", "unit", "food", "checked"]],
},
} }
static RECIPE_BOOK = { static RECIPE_BOOK = {
@ -370,41 +407,15 @@ export class Models {
name: i18n.t("Recipe"), name: i18n.t("Recipe"),
apiName: "Recipe", apiName: "Recipe",
list: { list: {
params: [ params: ["query", "keywords", "foods", "units", "rating", "books", "keywordsOr", "foodsOr", "booksOr", "internal", "random", "_new", "page", "pageSize", "options"],
"query", // 'config': {
"keywords", // 'foods': {'type': 'string'},
"foods", // 'keywords': {'type': 'string'},
"units", // 'books': {'type': 'string'},
"rating", // }
"books",
"steps",
"keywordsOr",
"foodsOr",
"booksOr",
"internal",
"random",
"_new",
"page",
"pageSize",
"options",
],
config: {
foods: { type: "string" },
keywords: { type: "string" },
books: { type: "string" },
},
}, },
} shopping: {
params: ["id", ["id", "list_recipe", "ingredients", "servings"]],
static STEP = {
name: i18n.t("Step"),
apiName: "Step",
paginated: true,
list: {
header_component: {
name: "BetaWarning",
},
params: ["query", "page", "pageSize", "options"],
}, },
} }
@ -461,6 +472,11 @@ export class Models {
}, },
}, },
} }
static USER = {
name: i18n.t("User"),
apiName: "User",
paginated: false,
}
} }
export class Actions { 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" import { Actions, Models } from "./models"
export const ToastMixin = { export const ToastMixin = {
name: "ToastMixin",
methods: { methods: {
makeToast: function (title, message, variant = null) { makeToast: function (title, message, variant = null) {
return makeToast(title, message, variant) return makeToast(title, message, variant)
@ -147,12 +148,17 @@ export function resolveDjangoUrl(url, params = null) {
/* /*
* other utilities * other utilities
* */ * */
export function getUserPreference(pref = undefined) {
export function getUserPreference(pref) { let user_preference
if (window.USER_PREF === undefined) { if (document.getElementById("user_preference")) {
user_preference = JSON.parse(document.getElementById("user_preference").textContent)
} else {
return undefined return undefined
} }
return window.USER_PREF[pref] if (pref) {
return user_preference[pref]
}
return user_preference
} }
export function calculateAmount(amount, factor) { export function calculateAmount(amount, factor) {
@ -214,6 +220,11 @@ export const ApiMixin = {
return { return {
Models: Models, Models: Models,
Actions: Actions, Actions: Actions,
FoodCreateDefault: function (form) {
form.inherit_ignore = getUserPreference("food_ignore_default")
form.inherit = form.supermarket_category.length > 0
return form
},
} }
}, },
methods: { 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", entry: "./src/apps/MealPlanView/main.js",
chunks: ["chunk-vendors"], chunks: ["chunk-vendors"],
}, },
checklist_view: { shopping_list_view: {
entry: "./src/apps/ChecklistView/main.js", entry: "./src/apps/ShoppingListView/main.js",
chunks: ["chunk-vendors"], chunks: ["chunk-vendors"],
}, },
} }
@ -47,7 +47,7 @@ module.exports = {
pages: pages, pages: pages,
filenameHashing: false, filenameHashing: false,
productionSourceMap: 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/", outputDir: "../cookbook/static/vue/",
runtimeCompiler: true, runtimeCompiler: true,
pwa: { pwa: {
@ -90,18 +90,9 @@ module.exports = {
}, },
}, },
// TODO make this conditional on .env DEBUG = FALSE // 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.plugin("BundleTracker").use(BundleTracker, [{ relativePath: true, path: "../vue/" }])
config.resolve.alias.set("__STATIC__", "static") config.resolve.alias.set("__STATIC__", "static")